InvestmentTrackerApp/PortfolioJournal/Views/Settings/SettingsView.swift

712 lines
23 KiB
Swift

import SwiftUI
import StoreKit
struct SettingsView: View {
private let 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(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
// 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 \(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
}
}
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(iapService: IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}