initial version
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user