773da6800b
iCloud sync: - Fix: deploy CloudKit schema to production so exports work - Fix: PredictionCache marked syncable=NO (binary attr caused partial failures) - Fix: forceExportToiCloud uses createdAt+1ms for guaranteed persistent history - Fix: auto-deduplication on CloudKit import (processRemoteChanges) - Add: NSPersistentHistoryTrackingKey always enabled regardless of CloudKit state - Add: cloudKitForceReload notification so repositories re-fetch on remote changes Sources UI: - Add: horizontal category filter chips in SourceListView - Add: search toggle button with animated TextField Other: - Add: ITSAppUsesNonExemptEncryption=NO in Info.plist (skip manual compliance) - Add: fastlane submit lane with run_precheck_before_submit=false - Update: release notes all locales for 1.3.1 - Update: fastlane API key via pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1039 lines
36 KiB
Swift
1039 lines
36 KiB
Swift
import StoreKit
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct SettingsView: View {
|
|
private let iapService: IAPService
|
|
@StateObject private var viewModel: SettingsViewModel
|
|
@EnvironmentObject private var adMobService: AdMobService
|
|
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
|
@AppStorage("showForecast") private var showForecast = 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
|
|
|
|
@ObservedObject private var cloudStack = CoreDataStack.shared
|
|
|
|
@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 showingPinVerifyForFaceId = false
|
|
@State private var showingRestartAlert = false
|
|
@State private var didLoadCloudSync = false
|
|
@State private var isForceUploading = false
|
|
@State private var forceUploadResult: String?
|
|
@State private var backupToRestore: BackupRecord?
|
|
|
|
init(iapService: IAPService) {
|
|
self.iapService = iapService
|
|
_viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: iapService))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
AppBackground()
|
|
|
|
List {
|
|
brandSection
|
|
// Premium Section
|
|
premiumSection
|
|
|
|
// Notifications Section
|
|
notificationsSection
|
|
|
|
// Data Section
|
|
dataSection
|
|
if viewModel.backupsEnabled {
|
|
backupsSection
|
|
}
|
|
|
|
// 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(item: $viewModel.shareItem) { shareItem in
|
|
ActivityView(activityItems: [shareItem.url])
|
|
}
|
|
.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.")
|
|
}
|
|
.confirmationDialog(
|
|
"Restore Backup",
|
|
isPresented: Binding(
|
|
get: { backupToRestore != nil },
|
|
set: { if !$0 { backupToRestore = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Restore", role: .destructive) {
|
|
if let backup = backupToRestore {
|
|
viewModel.restoreBackup(backup)
|
|
backupToRestore = nil
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
backupToRestore = nil
|
|
}
|
|
} message: {
|
|
Text("This will replace your current data with the selected backup.")
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingPinVerifyForFaceId) {
|
|
PinVerifyView(title: "Enter PIN to Disable Face ID") { success in
|
|
if success {
|
|
faceIdEnabled = false
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
didLoadCloudSync = true
|
|
if viewModel.backupsEnabled {
|
|
viewModel.refreshBackups()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 \(iapService.formattedPrice)")
|
|
.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
|
|
}
|
|
viewModel.refreshBackups()
|
|
}
|
|
|
|
if cloudSyncEnabled {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
// Local data counts
|
|
HStack {
|
|
Label(
|
|
"\(cloudStack.localSourceCount) sources · \(cloudStack.localSnapshotCount) snapshots",
|
|
systemImage: "internaldrive"
|
|
)
|
|
.font(.subheadline)
|
|
.foregroundColor(.primary)
|
|
|
|
Spacer()
|
|
|
|
Button("Refresh") {
|
|
CoreDataStack.shared.forceReload()
|
|
}
|
|
.font(.subheadline)
|
|
.disabled(cloudStack.isSyncing)
|
|
}
|
|
|
|
// Import / export status
|
|
if cloudStack.isSyncing {
|
|
Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
if let date = cloudStack.lastImportDate {
|
|
Label("↓ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
Label("No import yet", systemImage: "icloud.and.arrow.down")
|
|
.font(.caption)
|
|
.foregroundColor(.orange)
|
|
}
|
|
|
|
if let date = cloudStack.lastExportDate {
|
|
Label("↑ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
Label("No export yet", systemImage: "icloud.and.arrow.up")
|
|
.font(.caption)
|
|
.foregroundColor(.orange)
|
|
}
|
|
}
|
|
}
|
|
|
|
// CloudKit error (if any)
|
|
if let error = cloudStack.lastSyncError {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Label("CloudKit error", systemImage: "exclamationmark.icloud")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(.red)
|
|
Text(error)
|
|
.font(.caption2)
|
|
.foregroundColor(.red)
|
|
.lineLimit(6)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
// Force upload — use when data exists locally but hasn't reached iCloud
|
|
if cloudStack.localSourceCount > 0 {
|
|
if let result = forceUploadResult {
|
|
Label(result, systemImage: "checkmark.icloud")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
Button {
|
|
isForceUploading = true
|
|
CoreDataStack.shared.forceExportToiCloud { count in
|
|
isForceUploading = false
|
|
forceUploadResult = String(
|
|
format: NSLocalizedString("save_n_snapshots", comment: ""),
|
|
count
|
|
)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
|
forceUploadResult = nil
|
|
}
|
|
}
|
|
} label: {
|
|
if isForceUploading {
|
|
Label("Uploading...", systemImage: "arrow.triangle.2.circlepath.icloud")
|
|
} else {
|
|
Label("Force Upload to iCloud", systemImage: "icloud.and.arrow.up")
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.appPrimary)
|
|
.disabled(isForceUploading)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
Toggle(
|
|
isOn: Binding(
|
|
get: { viewModel.backupsEnabled },
|
|
set: { viewModel.setBackupsEnabled($0) }
|
|
)
|
|
) {
|
|
HStack(spacing: 8) {
|
|
Text("Enable Backups")
|
|
if !viewModel.isPremium {
|
|
Text("Premium")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(.appWarning)
|
|
}
|
|
}
|
|
}
|
|
|
|
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: - Backups Section
|
|
|
|
private var backupsSection: some View {
|
|
Section {
|
|
Picker("Keep Backups", selection: $viewModel.backupRetentionCount) {
|
|
Text("5").tag(5)
|
|
Text("10").tag(10)
|
|
Text("20").tag(20)
|
|
}
|
|
.onChange(of: viewModel.backupRetentionCount) { _, newValue in
|
|
viewModel.updateBackupRetention(newValue)
|
|
}
|
|
|
|
Button {
|
|
viewModel.createBackupNow()
|
|
} label: {
|
|
HStack {
|
|
Label("Create Backup Now", systemImage: "arrow.clockwise")
|
|
Spacer()
|
|
if viewModel.isBackupInProgress {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.disabled(viewModel.isBackupInProgress || viewModel.isRestoreInProgress)
|
|
|
|
if viewModel.backups.isEmpty {
|
|
Text("No backups yet.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
ForEach(viewModel.backups) { backup in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(backup.date.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.subheadline)
|
|
Text("\(backup.location.rawValue) · \(formatBytes(backup.size))")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
Button("Restore") {
|
|
backupToRestore = backup
|
|
}
|
|
.disabled(viewModel.isRestoreInProgress)
|
|
}
|
|
.contextMenu {
|
|
Button("Share Backup") {
|
|
viewModel.shareItem = SettingsViewModel.ShareItem(url: backup.url)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Backups")
|
|
} footer: {
|
|
Text("Backups are stored locally and in iCloud (when enabled).")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
} else {
|
|
faceIdEnabled = true
|
|
showingPinVerifyForFaceId = 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)
|
|
}
|
|
|
|
if !viewModel.isPremium {
|
|
Button("Manage Ad Consent") {
|
|
Task {
|
|
await adMobService.presentPrivacyOptions()
|
|
}
|
|
}
|
|
}
|
|
} 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)
|
|
|
|
Toggle("Show Forecast", isOn: $showForecast)
|
|
|
|
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. Show Forecast controls whether prediction data appears in portfolio and charts.")
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
return formatter.string(fromByteCount: bytes)
|
|
}
|
|
}
|
|
|
|
// MARK: - Activity View
|
|
|
|
struct ActivityView: UIViewControllerRepresentable {
|
|
let activityItems: [Any]
|
|
let applicationActivities: [UIActivity]? = nil
|
|
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
UIActivityViewController(
|
|
activityItems: activityItems,
|
|
applicationActivities: applicationActivities
|
|
)
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
|
}
|
|
|
|
// MARK: - Export Options Sheet
|
|
|
|
struct ExportOptionsSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@ObservedObject var viewModel: SettingsViewModel
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
if viewModel.isExporting {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
ProgressView(value: viewModel.exportProgress)
|
|
.progressViewStyle(.linear)
|
|
|
|
Text(viewModel.exportStatus)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.vertical, 8)
|
|
} header: {
|
|
Text("Exporting...")
|
|
}
|
|
} else {
|
|
Section {
|
|
Button {
|
|
viewModel.exportData(format: .csv)
|
|
} 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)
|
|
} 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(viewModel.isExporting ? "Exporting" : "Export Data")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
.disabled(viewModel.isExporting)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium])
|
|
.interactiveDismissDisabled(viewModel.isExporting)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
struct PinVerifyView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
let title: String
|
|
let onResult: (Bool) -> Void
|
|
|
|
@State private var pin = ""
|
|
@State private var errorMessage: String?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section {
|
|
SecureField("Enter PIN", text: $pin)
|
|
.keyboardType(.numberPad)
|
|
.onChange(of: pin) { _, newValue in
|
|
pin = 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") {
|
|
onResult(false)
|
|
dismiss()
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Verify") { verifyPin() }
|
|
.disabled(pin.count < 4)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium])
|
|
}
|
|
|
|
private func verifyPin() {
|
|
guard let savedPin = KeychainService.readPin(), pin == savedPin else {
|
|
errorMessage = "Incorrect PIN."
|
|
pin = ""
|
|
return
|
|
}
|
|
onResult(true)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SettingsView(iapService: IAPService())
|
|
.environmentObject(AccountStore(iapService: IAPService()))
|
|
}
|