Update version

This commit is contained in:
2026-01-16 11:28:26 +01:00
parent 7988257399
commit c6be398e5a
28 changed files with 1061 additions and 193 deletions
@@ -10,6 +10,7 @@ struct AccountEditorView: View {
@State private var inputMode: InputMode = .simple
@State private var notificationFrequency: NotificationFrequency = .monthly
@State private var customFrequencyMonths = 1
@State private var errorMessage: String?
private let accountRepository = AccountRepository()
@@ -25,6 +26,11 @@ struct AccountEditorView: View {
}
} header: {
Text("Account")
} footer: {
if let errorMessage {
Text(errorMessage)
.foregroundColor(.negativeRed)
}
}
Section {
@@ -39,25 +45,6 @@ struct AccountEditorView: View {
Text(inputMode.description)
}
Section {
Picker("Reminder Frequency", selection: $notificationFrequency) {
ForEach(NotificationFrequency.allCases) { frequency in
Text(frequency.displayName).tag(frequency)
}
}
if notificationFrequency == .custom {
Stepper(
"Every \(customFrequencyMonths) month\(customFrequencyMonths > 1 ? "s" : "")",
value: $customFrequencyMonths,
in: 1...24
)
}
} header: {
Text("Account Reminders")
} footer: {
Text("Reminders apply to the whole account.")
}
}
.navigationTitle(account == nil ? "New Account" : "Edit Account")
.navigationBarTitleDisplayMode(.inline)
@@ -73,12 +60,35 @@ struct AccountEditorView: View {
.onAppear {
loadAccount()
}
.onChange(of: name) { _, _ in
validateName()
}
}
.presentationDetents([.large])
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty
let trimmed = name.trimmingCharacters(in: .whitespaces)
return !trimmed.isEmpty && !isDuplicateName(trimmed)
}
private func validateName() {
let trimmed = name.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
errorMessage = nil
return
}
errorMessage = isDuplicateName(trimmed) ? "An account with this name already exists." : nil
}
private func isDuplicateName(_ trimmed: String) -> Bool {
let normalized = trimmed.lowercased()
return accountRepository.accounts.contains { existing in
if let account, existing.id == account.id {
return false
}
return (existing.name).trimmingCharacters(in: .whitespaces).lowercased() == normalized
}
}
private func loadAccount() {
@@ -86,27 +96,33 @@ struct AccountEditorView: View {
name = account.name
currencyCode = account.currencyCode ?? currencyCode
inputMode = InputMode(rawValue: account.inputMode) ?? .simple
notificationFrequency = account.frequency
customFrequencyMonths = Int(account.customFrequencyMonths)
notificationFrequency = .monthly
customFrequencyMonths = 1
validateName()
}
private func saveAccount() {
validateName()
guard errorMessage == nil else { return }
let trimmedName = name.trimmingCharacters(in: .whitespaces)
let enforcedFrequency: NotificationFrequency = .monthly
let enforcedCustomMonths = 1
if let account {
accountRepository.updateAccount(
account,
name: name.trimmingCharacters(in: .whitespaces),
name: trimmedName,
currency: currencyCode,
inputMode: inputMode,
notificationFrequency: notificationFrequency,
customFrequencyMonths: customFrequencyMonths
notificationFrequency: enforcedFrequency,
customFrequencyMonths: enforcedCustomMonths
)
} else {
_ = accountRepository.createAccount(
name: name.trimmingCharacters(in: .whitespaces),
name: trimmedName,
currency: currencyCode,
inputMode: inputMode,
notificationFrequency: notificationFrequency,
customFrequencyMonths: customFrequencyMonths
notificationFrequency: enforcedFrequency,
customFrequencyMonths: enforcedCustomMonths
)
}
@@ -7,6 +7,7 @@ struct AccountsView: View {
@State private var showingAddAccount = false
@State private var selectedAccount: Account?
@State private var showingPaywall = false
@State private var accountToDelete: Account?
var body: some View {
ZStack {
@@ -33,6 +34,13 @@ struct AccountsView: View {
}
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
accountToDelete = account
} label: {
Label("Delete", systemImage: "trash")
}
}
}
} header: {
Text("Accounts")
@@ -43,6 +51,25 @@ struct AccountsView: View {
.scrollContentBackground(.hidden)
}
.navigationTitle("Accounts")
.confirmationDialog(
"Delete Account",
isPresented: Binding(
get: { accountToDelete != nil },
set: { if !$0 { accountToDelete = nil } }
),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
guard let accountToDelete else { return }
if accountStore.selectedAccount?.id == accountToDelete.id {
accountStore.selectAllAccounts()
}
accountRepository.deleteAccount(accountToDelete)
self.accountToDelete = nil
}
} message: {
Text("This will remove the account and unlink its sources.")
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
@@ -17,6 +17,35 @@ struct LoadingView: View {
}
}
struct AppLaunchLoadingView: View {
var messageKey: LocalizedStringKey = "loading"
var body: some View {
VStack(spacing: 20) {
Spacer()
Image("BrandMark")
.resizable()
.scaledToFit()
.frame(width: 140, height: 140)
.padding(16)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(28)
ProgressView()
.scaleEffect(1.2)
Text(messageKey)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppBackground())
}
}
// MARK: - Skeleton Loading
struct SkeletonView: View {
@@ -43,7 +43,7 @@ struct DashboardView: View {
viewModel.refreshData()
}
.overlay {
if viewModel.isLoading {
if viewModel.isLoading && !viewModel.hasData {
ProgressView()
}
}
@@ -117,7 +117,7 @@ struct OnboardingView: View {
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
}
.sheet(isPresented: $showingImportSheet) {
ImportDataView()
ImportDataView(importContext: .onboarding)
}
.sheet(isPresented: $showingAddSource) {
AddSourceView()
@@ -120,15 +120,9 @@ struct PaywallView: View {
private var priceCard: some View {
VStack(spacing: 8) {
HStack(alignment: .top, spacing: 4) {
Text("")
.font(.title2.weight(.semibold))
.foregroundColor(.appPrimary)
Text("4.69")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundColor(.appPrimary)
}
Text(iapService.formattedPrice)
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundColor(.appPrimary)
Text("One-time purchase")
.font(.subheadline)
@@ -283,6 +277,7 @@ struct FeatureRow: View {
// MARK: - Compact Paywall (for inline use)
struct CompactPaywallBanner: View {
@EnvironmentObject var iapService: IAPService
@Binding var showingPaywall: Bool
var body: some View {
@@ -311,7 +306,7 @@ struct CompactPaywallBanner: View {
Button {
showingPaywall = true
} label: {
Text("€4.69")
Text(iapService.formattedPrice)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 6)
@@ -3,6 +3,20 @@ import UniformTypeIdentifiers
import UIKit
struct ImportDataView: View {
enum ImportContext {
case settings
case onboarding
}
enum AccountSelection: String, CaseIterable, Identifiable {
case existing
case new
var id: String { rawValue }
}
let importContext: ImportContext
@EnvironmentObject private var iapService: IAPService
@EnvironmentObject private var accountStore: AccountStore
@EnvironmentObject private var tabSelection: TabSelectionStore
@@ -15,10 +29,24 @@ struct ImportDataView: View {
@State private var isImporting = false
@State private var importProgress: Double = 0
@State private var importStatus = "Preparing import"
@State private var accountSelection: AccountSelection = .existing
@State private var selectedAccountId: UUID?
@State private var newAccountName = ""
@State private var accountErrorMessage: String?
private let accountRepository = AccountRepository()
init(importContext: ImportContext = .settings) {
self.importContext = importContext
}
var body: some View {
NavigationStack {
List {
if shouldShowAccountSelection {
accountSection
}
Section {
Picker("Format", selection: $selectedFormat) {
Text("CSV").tag(ImportService.ImportFormat.csv)
@@ -65,7 +93,7 @@ struct ImportDataView: View {
} header: {
Text("Format Guide")
} footer: {
Text(iapService.isPremium ? "Accounts are imported as provided." : "Free users import into the Personal account.")
Text(importFooterText)
}
Section {
@@ -117,6 +145,18 @@ struct ImportDataView: View {
} message: {
Text(errorMessage ?? "")
}
.onAppear {
if selectedAccountId == nil {
selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id
}
}
.onChange(of: accountSelection) { _, _ in
accountErrorMessage = nil
}
.onChange(of: newAccountName) { _, _ in
guard accountSelection == .new else { return }
accountErrorMessage = validateNewAccountName()
}
}
.presentationDetents([.large])
}
@@ -178,6 +218,78 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
}
}
private var shouldShowAccountSelection: Bool {
importContext == .onboarding
}
private var importFooterText: String {
if importContext == .onboarding {
return iapService.isPremium
? "Import will be added to the selected account."
: "Free users import into the existing account."
}
return iapService.isPremium
? "Accounts are imported as provided."
: "Free users import into the selected account."
}
private var accountSection: some View {
Section {
if iapService.isPremium {
Picker("Account", selection: $accountSelection) {
Text("Existing").tag(AccountSelection.existing)
Text("New").tag(AccountSelection.new)
}
.pickerStyle(.segmented)
.disabled(isImporting)
if accountSelection == .existing {
Picker("Import into", selection: $selectedAccountId) {
ForEach(accountStore.accounts) { account in
Text(account.name).tag(Optional(account.id))
}
}
.disabled(isImporting)
} else {
TextField("New account name", text: $newAccountName)
.disabled(isImporting)
}
} else {
HStack {
Text("Account")
Spacer()
Text(selectedAccountName ?? "Personal")
.foregroundColor(.secondary)
}
}
} header: {
Text("Account")
} footer: {
if let accountErrorMessage {
Text(accountErrorMessage)
.foregroundColor(.negativeRed)
}
}
}
private var selectedAccountName: String? {
accountStore.accounts.first { $0.id == selectedAccountId }?.name
?? accountStore.selectedAccount?.name
?? accountStore.accounts.first?.name
}
private func validateNewAccountName() -> String? {
let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return "Enter a name for the new account."
}
let normalized = trimmed.lowercased()
let exists = accountStore.accounts.contains {
$0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
}
return exists ? "An account with this name already exists." : nil
}
private func handleImport(_ result: Result<[URL], Error>) {
do {
let urls = try result.get()
@@ -243,9 +355,30 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
}
private func handleImportContent(_ content: String) {
let allowMultipleAccounts = iapService.isPremium
let defaultAccountName = accountStore.selectedAccount?.name
let shouldForceSingleAccount = importContext == .onboarding
let allowMultipleAccounts = iapService.isPremium && !shouldForceSingleAccount
var defaultAccountName = accountStore.selectedAccount?.name
?? accountStore.accounts.first?.name
if shouldForceSingleAccount && iapService.isPremium {
if accountSelection == .new {
accountErrorMessage = validateNewAccountName()
guard accountErrorMessage == nil else { return }
let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines)
let currency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let account = accountRepository.createAccount(
name: trimmed,
currency: currency,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1
)
defaultAccountName = account.name
} else {
defaultAccountName = selectedAccountName
}
}
isImporting = true
importProgress = 0
importStatus = "Parsing file"
@@ -2,7 +2,7 @@ import SwiftUI
import StoreKit
struct SettingsView: View {
@EnvironmentObject var iapService: IAPService
private let iapService: IAPService
@StateObject private var viewModel: SettingsViewModel
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
@@ -19,8 +19,9 @@ struct SettingsView: View {
@State private var showingRestartAlert = false
@State private var didLoadCloudSync = false
init() {
_viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: IAPService()))
init(iapService: IAPService) {
self.iapService = iapService
_viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: iapService))
}
var body: some View {
@@ -221,7 +222,7 @@ struct SettingsView: View {
.font(.headline)
.foregroundColor(.primary)
Text("Unlock all features for €4.69")
Text("Unlock all features for \(iapService.formattedPrice)")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -705,7 +706,6 @@ struct PinSetupView: View {
}
#Preview {
SettingsView()
.environmentObject(IAPService())
SettingsView(iapService: IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}