712 lines
23 KiB
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()))
|
|
}
|