initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
@@ -0,0 +1,90 @@
import SwiftUI
struct AllocationTargetsView: View {
@StateObject private var categoryRepository = CategoryRepository()
@State private var targetValues: [UUID: String] = [:]
var body: some View {
List {
Section {
HStack {
Text("Total Targets")
Spacer()
Text(String(format: "%.0f%%", totalTargets))
.foregroundColor(totalTargets == 100 ? .positiveGreen : .secondary)
}
if totalTargets != 100 {
Text("Targets don't need to be perfect, but aiming for 100% keeps the drift view accurate.")
.font(.caption)
.foregroundColor(.secondary)
}
}
Section {
ForEach(categoryRepository.categories) { category in
let categoryId = category.id
HStack {
HStack(spacing: 8) {
Circle()
.fill(category.color)
.frame(width: 10, height: 10)
Text(category.name)
}
Spacer()
TextField("0", text: targetBinding(for: categoryId))
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
Text("%")
.foregroundColor(.secondary)
}
}
} header: {
Text("Targets by Category")
} footer: {
Text("Set your desired allocation percentages to track drift over time.")
}
}
.navigationTitle("Allocation Targets")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
preloadTargets()
}
}
private var totalTargets: Double {
let ids = categoryRepository.categories.compactMap { $0.id }
return AllocationTargetStore.totalTargetPercentage(for: ids)
}
private func preloadTargets() {
targetValues = categoryRepository.categories.reduce(into: [:]) { result, category in
let id = category.id
if let target = AllocationTargetStore.target(for: id) {
result[id] = String(format: "%.0f", target)
}
}
}
private func targetBinding(for categoryId: UUID) -> Binding<String> {
Binding(
get: { targetValues[categoryId] ?? "" },
set: { newValue in
targetValues[categoryId] = newValue
let sanitized = newValue.replacingOccurrences(of: ",", with: ".")
let value = Double(sanitized) ?? 0
AllocationTargetStore.setTarget(value, for: categoryId)
}
)
}
}
#Preview {
NavigationStack {
AllocationTargetsView()
}
}
@@ -0,0 +1,295 @@
import SwiftUI
import UniformTypeIdentifiers
import UIKit
struct ImportDataView: View {
@EnvironmentObject private var iapService: IAPService
@EnvironmentObject private var accountStore: AccountStore
@EnvironmentObject private var tabSelection: TabSelectionStore
@Environment(\.dismiss) private var dismiss
@State private var selectedFormat: ImportService.ImportFormat = .csv
@State private var showingImporter = false
@State private var resultMessage: String?
@State private var errorMessage: String?
@State private var isImporting = false
@State private var importProgress: Double = 0
@State private var importStatus = "Preparing import"
var body: some View {
NavigationStack {
List {
Section {
Picker("Format", selection: $selectedFormat) {
Text("CSV").tag(ImportService.ImportFormat.csv)
Text("JSON").tag(ImportService.ImportFormat.json)
}
.pickerStyle(.segmented)
.disabled(isImporting)
Button {
showingImporter = true
} label: {
Label("Choose File", systemImage: "doc")
}
.disabled(isImporting)
Button {
importFromClipboard()
} label: {
Label("Paste from Clipboard", systemImage: "doc.on.clipboard")
}
.disabled(isImporting)
if isImporting {
VStack(alignment: .leading, spacing: 8) {
ProgressView(value: importProgress)
Text(importStatus)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.top, 8)
}
} header: {
Text("Import")
} footer: {
Text("Your data will be merged with existing categories and sources.")
}
Section {
if selectedFormat == .csv {
csvDocs
} else {
jsonDocs
}
} header: {
Text("Format Guide")
} footer: {
Text(iapService.isPremium ? "Accounts are imported as provided." : "Free users import into the Personal account.")
}
Section {
Button {
shareSampleFile()
} label: {
Label("Share Sample \(selectedFormat == .csv ? "CSV" : "JSON")", systemImage: "square.and.arrow.up")
}
} footer: {
Text("Use this sample file to email yourself a template.")
}
}
.navigationTitle("Import Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }
.disabled(isImporting)
}
}
.fileImporter(
isPresented: $showingImporter,
allowedContentTypes: selectedFormat == .csv
? [.commaSeparatedText, .plainText, .text]
: [.json, .plainText, .text],
allowsMultipleSelection: false
) { result in
handleImport(result)
}
.alert(
"Import Complete",
isPresented: Binding(
get: { resultMessage != nil },
set: { if !$0 { resultMessage = nil } }
)
) {
Button("OK") { resultMessage = nil }
} message: {
Text(resultMessage ?? "")
}
.alert(
"Import Error",
isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)
) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
}
.presentationDetents([.large])
}
private var csvDocs: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Headers")
.font(.headline)
Text("Account,Category,Source,Date,Value,Contribution,Notes")
.font(.caption.monospaced())
Text("Example")
.font(.headline)
Text("""
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
,Crypto,BTC,01/15/2024 14:30,3200,,Cold storage
""")
.font(.caption.monospaced())
Text("Account, Contribution, and Notes are optional. Dates accept / or - and 24h/12h time.")
.font(.caption)
.foregroundColor(.secondary)
}
}
private var jsonDocs: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top-level keys")
.font(.headline)
Text("version, currency, accounts")
.font(.caption.monospaced())
Text("Example")
.font(.headline)
Text("""
{
"version": 2,
"currency": "EUR",
"accounts": [{
"name": "Personal",
"inputMode": "simple",
"notificationFrequency": "monthly",
"categories": [{
"name": "Stocks",
"color": "#3B82F6",
"icon": "chart.line.uptrend.xyaxis",
"sources": [{
"name": "Index Fund",
"snapshots": [{
"date": "2024-01-01T00:00:00Z",
"value": 15000,
"contribution": 12000
}]
}]
}]
}]
}
""")
.font(.caption.monospaced())
}
}
private func handleImport(_ result: Result<[URL], Error>) {
do {
let urls = try result.get()
guard let url = urls.first else { return }
let content = try readFileContents(from: url)
handleImportContent(content)
} catch {
errorMessage = "Could not read the selected file. \(error.localizedDescription)"
}
}
private func readFileContents(from url: URL) throws -> String {
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
var coordinatorError: NSError?
var contentError: NSError?
var content = ""
let coordinator = NSFileCoordinator()
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinatorError) { fileURL in
do {
if let isUbiquitous = try? fileURL.resourceValues(forKeys: [.isUbiquitousItemKey]).isUbiquitousItem,
isUbiquitous {
try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
}
let data = try Data(contentsOf: fileURL)
if let decoded = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .utf16)
?? String(data: data, encoding: .isoLatin1) {
content = decoded
} else {
throw NSError(
domain: "ImportDataView",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Unsupported text encoding."]
)
}
} catch {
contentError = error as NSError
}
}
if let coordinatorError {
throw coordinatorError
}
if let contentError {
throw contentError
}
return content
}
private func importFromClipboard() {
guard let content = UIPasteboard.general.string,
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
errorMessage = "Clipboard is empty."
return
}
handleImportContent(content)
}
private func handleImportContent(_ content: String) {
let allowMultipleAccounts = iapService.isPremium
let defaultAccountName = accountStore.selectedAccount?.name
?? accountStore.accounts.first?.name
isImporting = true
importProgress = 0
importStatus = "Parsing file"
Task {
let importResult = await ImportService.shared.importDataAsync(
content: content,
format: selectedFormat,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
) { progress in
importProgress = progress.fraction
importStatus = progress.message
}
isImporting = false
if importResult.errors.isEmpty {
resultMessage = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
tabSelection.selectedTab = 0
dismiss()
} else {
errorMessage = importResult.errors.joined(separator: "\n")
}
}
}
private func shareSampleFile() {
if selectedFormat == .csv {
ShareService.shared.shareTextFile(
content: ImportService.sampleCSV(),
fileName: "investment_tracker_sample.csv"
)
} else {
ShareService.shared.shareTextFile(
content: ImportService.sampleJSON(),
fileName: "investment_tracker_sample.json"
)
}
}
}
#Preview {
ImportDataView()
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}
@@ -0,0 +1,711 @@
import SwiftUI
import StoreKit
struct SettingsView: View {
@EnvironmentObject var iapService: IAPService
@StateObject private var viewModel: SettingsViewModel
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
@AppStorage("pinEnabled") private var pinEnabled = false
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
@AppStorage("lockOnBackground") private var lockOnBackground = false
@State private var showingPinSetup = false
@State private var showingPinChange = false
@State private var showingBiometricAlert = false
@State private var showingPinRequiredAlert = false
@State private var showingPinDisableAlert = false
@State private var showingRestartAlert = false
@State private var didLoadCloudSync = false
init() {
_viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: IAPService()))
}
var body: some View {
NavigationStack {
ZStack {
AppBackground()
List {
brandSection
// Premium Section
premiumSection
// Notifications Section
notificationsSection
// Data Section
dataSection
// Security Section
securitySection
// Preferences Section
preferencesSection
// Long-Term Focus
longTermSection
// Accounts Section
accountsSection
// About Section
aboutSection
// Danger Zone
dangerZoneSection
}
.scrollContentBackground(.hidden)
}
.navigationTitle("Settings")
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.sheet(isPresented: $viewModel.showingExportOptions) {
ExportOptionsSheet(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.showingImportSheet) {
ImportDataView()
}
.confirmationDialog(
"Reset All Data",
isPresented: $viewModel.showingResetConfirmation,
titleVisibility: .visible
) {
Button("Reset Everything", role: .destructive) {
viewModel.resetAllData()
}
} message: {
Text("This will permanently delete all your investment data. This action cannot be undone.")
}
.alert("Success", isPresented: .constant(viewModel.successMessage != nil)) {
Button("OK") {
viewModel.successMessage = nil
}
} message: {
Text(viewModel.successMessage ?? "")
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
.alert("Restart Required", isPresented: $showingRestartAlert) {
Button("OK") {}
} message: {
Text("Restart the app to apply iCloud sync changes.")
}
.alert("Face ID Unavailable", isPresented: $showingBiometricAlert) {
Button("OK") {}
} message: {
Text("Face ID isn't available on this device.")
}
.alert("PIN Required", isPresented: $showingPinRequiredAlert) {
Button("OK") {}
} message: {
Text("Enable a PIN before turning on Face ID.")
}
.alert("Disable Face ID First", isPresented: $showingPinDisableAlert) {
Button("OK") {}
} message: {
Text("Turn off Face ID before disabling your PIN.")
}
.sheet(isPresented: $showingPinSetup) {
PinSetupView(title: "Set PIN") { pin in
if KeychainService.savePin(pin) {
pinEnabled = true
} else {
pinEnabled = false
}
}
.onDisappear {
if KeychainService.readPin() == nil {
pinEnabled = false
}
}
}
.sheet(isPresented: $showingPinChange) {
PinSetupView(title: "Change PIN") { pin in
_ = KeychainService.savePin(pin)
}
}
.onAppear {
didLoadCloudSync = true
}
}
}
// MARK: - Brand Section
private var brandSection: some View {
Section {
HStack(spacing: 12) {
Image("BrandMark")
.resizable()
.scaledToFit()
.frame(width: 36, height: 36)
.padding(6)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 2) {
Text(appDisplayName)
.font(.headline)
Text("Long-term portfolio tracker")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
}
// MARK: - Premium Section
private var premiumSection: some View {
Section {
if viewModel.isPremium {
HStack {
ZStack {
Circle()
.fill(Color.yellow.opacity(0.2))
.frame(width: 44, height: 44)
Image(systemName: "crown.fill")
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
VStack(alignment: .leading, spacing: 2) {
Text("Premium Active")
.font(.headline)
if viewModel.isFamilyShared {
Text("Family Sharing")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.positiveGreen)
}
} else {
Button {
viewModel.upgradeToPremium()
} label: {
HStack {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: "crown.fill")
.foregroundColor(.appPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text("Upgrade to Premium")
.font(.headline)
.foregroundColor(.primary)
Text("Unlock all features for €4.69")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
}
Button {
Task {
await viewModel.restorePurchases()
}
} label: {
Text("Restore Purchases")
}
}
} header: {
Text("Subscription")
} footer: {
if !viewModel.isPremium {
Text("Free: \(viewModel.sourceLimitText)\(viewModel.historyLimitText)")
}
}
}
// MARK: - Notifications Section
private var notificationsSection: some View {
Section {
HStack {
Text("Notifications")
Spacer()
Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled")
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
.onTapGesture {
if !viewModel.notificationsEnabled {
Task {
await viewModel.requestNotificationPermission()
}
} else {
viewModel.openSystemSettings()
}
}
if viewModel.notificationsEnabled {
DatePicker(
"Default Reminder Time",
selection: $viewModel.defaultNotificationTime,
displayedComponents: .hourAndMinute
)
.onChange(of: viewModel.defaultNotificationTime) { _, newTime in
viewModel.updateNotificationTime(newTime)
}
}
} header: {
Text("Notifications")
} footer: {
Text("Set when you'd like to receive investment update reminders.")
}
}
// MARK: - Data Section
private var dataSection: some View {
Section {
Toggle("Sync with iCloud", isOn: $cloudSyncEnabled)
.onChange(of: cloudSyncEnabled) { _, _ in
if didLoadCloudSync {
showingRestartAlert = true
}
}
Button {
if viewModel.canExport {
viewModel.showingExportOptions = true
} else {
viewModel.showingPaywall = true
}
} label: {
HStack {
Label("Export Data", systemImage: "square.and.arrow.up")
Spacer()
if !viewModel.canExport {
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.appWarning)
}
}
}
Button {
viewModel.showingImportSheet = true
} label: {
HStack {
Label("Import Data", systemImage: "square.and.arrow.down")
Spacer()
}
}
HStack {
Text("Total Sources")
Spacer()
Text("\(viewModel.totalSources)")
.foregroundColor(.secondary)
}
HStack {
Text("Total Snapshots")
Spacer()
Text("\(viewModel.totalSnapshots)")
.foregroundColor(.secondary)
}
HStack {
Text("Storage Used")
Spacer()
Text(viewModel.storageUsedText)
.foregroundColor(.secondary)
}
} header: {
Text("Data")
}
}
// MARK: - Security Section
private var securitySection: some View {
Section {
Toggle("Require PIN", isOn: $pinEnabled)
.onChange(of: pinEnabled) { _, enabled in
if enabled {
if KeychainService.readPin() == nil {
showingPinSetup = true
}
} else {
if faceIdEnabled {
pinEnabled = true
showingPinDisableAlert = true
} else {
KeychainService.deletePin()
}
}
}
Toggle("Enable Face ID", isOn: $faceIdEnabled)
.onChange(of: faceIdEnabled) { _, enabled in
if enabled {
guard AppLockService.canUseBiometrics() else {
faceIdEnabled = false
showingBiometricAlert = true
return
}
if !pinEnabled {
faceIdEnabled = false
showingPinRequiredAlert = true
}
}
}
if pinEnabled {
Button("Change PIN") {
showingPinChange = true
}
}
if faceIdEnabled || pinEnabled {
Toggle("Lock on App Launch", isOn: $lockOnLaunch)
Toggle("Lock When Backgrounded", isOn: $lockOnBackground)
}
} header: {
Text("App Lock")
} footer: {
Text("Use Face ID or a 4-digit PIN to protect your data.")
}
}
// MARK: - Preferences Section
private var preferencesSection: some View {
Section {
Picker("Currency", selection: $viewModel.currencyCode) {
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
Text(code).tag(code)
}
}
.onChange(of: viewModel.currencyCode) { _, newValue in
viewModel.updateCurrency(newValue)
}
Picker("Input Mode", selection: $viewModel.inputMode) {
ForEach(InputMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.onChange(of: viewModel.inputMode) { _, newValue in
viewModel.updateInputMode(newValue)
}
} header: {
Text("Preferences")
} footer: {
Text("Currency and input mode apply globally unless overridden per account.")
}
}
// MARK: - Long-Term Focus Section
private var longTermSection: some View {
Section {
Toggle("Calm Mode", isOn: $calmModeEnabled)
NavigationLink {
AllocationTargetsView()
} label: {
HStack {
Label("Allocation Targets", systemImage: "target")
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
}
} header: {
Text("Long-Term Focus")
} footer: {
Text("Calm Mode hides short-term noise and advanced charts, keeping the app focused on monthly check-ins.")
}
}
// MARK: - Accounts Section
private var accountsSection: some View {
Section {
NavigationLink {
AccountsView()
} label: {
HStack {
Label("Manage Accounts", systemImage: "person.2")
Spacer()
if !viewModel.isPremium {
Text("Premium")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Accounts")
} footer: {
Text(viewModel.isPremium ? "Create multiple accounts and switch between them." : "Free users can use one account.")
}
}
// MARK: - About Section
private var aboutSection: some View {
Section {
HStack {
Text("Version")
Spacer()
Text(viewModel.appVersion)
.foregroundColor(.secondary)
}
Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) {
HStack {
Text("Privacy Policy")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Link(destination: URL(string: AppConstants.URLs.termsOfService)!) {
HStack {
Text("Terms of Service")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Link(destination: URL(string: AppConstants.URLs.support)!) {
HStack {
Text("Support")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Button {
requestAppReview()
} label: {
HStack {
Text("Rate App")
Spacer()
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
} header: {
Text("About")
}
}
// MARK: - Danger Zone Section
private var dangerZoneSection: some View {
Section {
Button(role: .destructive) {
viewModel.showingResetConfirmation = true
} label: {
HStack {
Image(systemName: "trash")
Text("Reset All Data")
}
}
} header: {
Text("Danger Zone")
} footer: {
Text("This will permanently delete all your investment sources, snapshots, and settings.")
}
}
// MARK: - Helpers
private func requestAppReview() {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
if #available(iOS 18.0, *) {
AppStore.requestReview(in: scene)
} else {
SKStoreReviewController.requestReview(in: scene)
}
}
private var appDisplayName: String {
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
return name
}
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String {
return name
}
return "Portfolio Journal"
}
}
// MARK: - Export Options Sheet
struct ExportOptionsSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: SettingsViewModel
var body: some View {
NavigationStack {
List {
Section {
Button {
viewModel.exportData(format: .csv)
dismiss()
} label: {
HStack {
Image(systemName: "tablecells")
.foregroundColor(.positiveGreen)
.frame(width: 30)
VStack(alignment: .leading) {
Text("CSV")
.font(.headline)
Text("Compatible with Excel, Google Sheets")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Button {
viewModel.exportData(format: .json)
dismiss()
} label: {
HStack {
Image(systemName: "doc.text")
.foregroundColor(.appPrimary)
.frame(width: 30)
VStack(alignment: .leading) {
Text("JSON")
.font(.headline)
Text("Full data structure for backup")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Select Format")
}
}
.navigationTitle("Export Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
dismiss()
}
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - PIN Setup View
struct PinSetupView: View {
@Environment(\.dismiss) private var dismiss
let title: String
let onSave: (String) -> Void
@State private var pin = ""
@State private var confirmPin = ""
@State private var errorMessage: String?
var body: some View {
NavigationStack {
Form {
Section {
SecureField("New PIN", text: $pin)
.keyboardType(.numberPad)
.onChange(of: pin) { _, newValue in
pin = String(newValue.filter(\.isNumber).prefix(4))
}
SecureField("Confirm PIN", text: $confirmPin)
.keyboardType(.numberPad)
.onChange(of: confirmPin) { _, newValue in
confirmPin = String(newValue.filter(\.isNumber).prefix(4))
}
} header: {
Text("4-Digit PIN")
}
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { savePin() }
.disabled(pin.count < 4 || confirmPin.count < 4)
}
}
}
.presentationDetents([.medium])
}
private func savePin() {
guard pin.count == 4, pin == confirmPin else {
errorMessage = "PINs do not match."
confirmPin = ""
return
}
onSave(pin)
dismiss()
}
}
#Preview {
SettingsView()
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}