Base fixes and test harness

This commit is contained in:
alexandrev-tibco
2026-02-01 11:12:57 +01:00
parent f5b13ec924
commit e328767c4a
39 changed files with 3575 additions and 142 deletions
@@ -452,7 +452,7 @@ struct EvolutionChartView: View {
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
AxisValueLabel(format: .dateTime.month(.abbreviated).year())
}
}
.chartYAxis {
@@ -148,7 +148,7 @@ struct DashboardView: View {
changeText: calmModeEnabled
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
: viewModel.portfolioSummary.formattedDayChange,
changeLabel: calmModeEnabled ? "since last update" : "today",
changeLabel: calmModeEnabled ? "since last check-in" : "today",
isPositive: calmModeEnabled
? viewModel.latestPortfolioChange.absolute >= 0
: viewModel.isDayChangePositive,
@@ -989,7 +989,7 @@ struct PendingUpdatesCard: View {
.foregroundColor(.secondary)
}
ForEach(sources.prefix(3)) { source in
ForEach(sources.prefix(3), id: \.objectID) { source in
NavigationLink(destination: SourceDetailView(source: source, iapService: iapService)) {
HStack {
Circle()
@@ -85,7 +85,7 @@ struct EvolutionChartCard: View {
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 3)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
AxisValueLabel(format: .dateTime.month(.abbreviated).year())
}
}
.chartYAxis {
@@ -320,7 +320,7 @@ struct MonthlyCheckInView: View {
.font(.subheadline)
.foregroundColor(.secondary)
} else {
ForEach(viewModel.sources) { source in
ForEach(viewModel.sources, id: \.objectID) { source in
let latestSnapshot = source.latestSnapshot
let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot)
Button {
@@ -421,7 +421,7 @@ struct MonthlyCheckInView: View {
.font(.subheadline)
.foregroundColor(.secondary)
} else {
ForEach(viewModel.recentNotes) { snapshot in
ForEach(viewModel.recentNotes, id: \.objectID) { snapshot in
VStack(alignment: .leading, spacing: 4) {
Text(snapshot.source?.name ?? "Source")
.font(.subheadline.weight(.semibold))
@@ -17,13 +17,31 @@ struct GoalEditorView: View {
self.goal = goal
}
private var currencySymbol: String {
if let account = account, let code = account.currency, !code.isEmpty {
return CurrencyFormatter.symbol(for: code)
}
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
}
private var currencyCode: String {
if let account = account, let code = account.currency, !code.isEmpty {
return code
}
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
}
var body: some View {
NavigationStack {
Form {
Section {
TextField("Goal name", text: $name)
TextField("Target amount", text: $targetAmount)
.keyboardType(.decimalPad)
HStack {
Text(currencySymbol)
.foregroundColor(.secondary)
TextField("Target amount", text: $targetAmount)
.keyboardType(.decimalPad)
}
Toggle("Add target date", isOn: $includeTargetDate)
if includeTargetDate {
@@ -51,7 +69,7 @@ struct GoalEditorView: View {
guard let goal, !didLoadGoal else { return }
name = goal.name ?? ""
if let amount = goal.targetAmount?.decimalValue {
targetAmount = NSDecimalNumber(decimal: amount).stringValue
targetAmount = formatDecimalForInput(amount)
}
if let target = goal.targetDate {
includeTargetDate = true
@@ -87,10 +105,39 @@ struct GoalEditorView: View {
}
private func parseDecimal(_ value: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
let cleaned = value
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
if let result = formatter.number(from: cleaned)?.decimalValue {
return result
}
// Fallback for mixed locale input
let normalized = cleaned
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
.replacingOccurrences(of: ",", with: ".")
.replacingOccurrences(of: " ", with: "")
return Decimal(string: cleaned)
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
private func formatDecimalForInput(_ decimal: Decimal) -> String {
let locale = CurrencyFormatter.locale(for: currencyCode)
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
}
}
@@ -5,6 +5,7 @@ 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("cloudSyncEnabled") private var cloudSyncEnabled = false
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
@@ -431,6 +432,14 @@ struct SettingsView: View {
.onChange(of: viewModel.inputMode) { _, newValue in
viewModel.updateInputMode(newValue)
}
if !viewModel.isPremium {
Button("Manage Ad Consent") {
Task {
await adMobService.presentPrivacyOptions()
}
}
}
} header: {
Text("Preferences")
} footer: {
@@ -185,18 +185,27 @@ struct AddSourceView: View {
}
private func parseDecimal(_ string: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
let cleaned = string
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: ",", with: ".")
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US")
formatter.locale = locale
return formatter.number(from: cleaned)?.decimalValue
if let value = formatter.number(from: cleaned)?.decimalValue {
return value
}
let normalized = cleaned
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
.replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
private var currencySymbol: String {
@@ -206,6 +215,13 @@ struct AddSourceView: View {
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
}
private var currencyCode: String {
if let account = selectedAccount, let code = account.currencyCode {
return code
}
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
}
private var selectedAccount: Account? {
guard let id = selectedAccountId else { return nil }
return availableAccounts.first { $0.safeId == id }
@@ -1,4 +1,5 @@
import SwiftUI
import CoreData
import Charts
struct SourceDetailView: View {
@@ -7,7 +8,8 @@ struct SourceDetailView: View {
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@State private var showingDeleteConfirmation = false
@State private var editingSnapshot: Snapshot?
@Environment(\.managedObjectContext) private var viewContext
@State private var editingSnapshotSelection: SnapshotSelection?
init(source: InvestmentSource, iapService: IAPService) {
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
@@ -17,6 +19,19 @@ struct SourceDetailView: View {
}
var body: some View {
Group {
if viewModel.isDeleted {
Color.clear
.onAppear {
dismiss()
}
} else {
contentView
}
}
}
private var contentView: some View {
ZStack {
AppBackground()
@@ -49,7 +64,7 @@ struct SourceDetailView: View {
.padding()
}
}
.navigationTitle(viewModel.source.name)
.navigationTitle(viewModel.safeSourceName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@@ -70,12 +85,16 @@ struct SourceDetailView: View {
}
}
}
.sheet(isPresented: $viewModel.showingAddSnapshot) {
AddSnapshotView(source: viewModel.source)
}
.sheet(item: $editingSnapshot) { snapshot in
.sheet(isPresented: $viewModel.showingAddSnapshot) {
AddSnapshotView(source: viewModel.source)
}
.sheet(item: $editingSnapshotSelection) { selection in
if let snapshot = try? viewContext.existingObject(with: selection.id) as? Snapshot {
AddSnapshotView(source: viewModel.source, snapshot: snapshot)
} else {
MissingSnapshotView()
}
}
.sheet(isPresented: $viewModel.showingAddTransaction) {
AddTransactionView { type, date, shares, price, amount, notes in
viewModel.addTransaction(
@@ -100,13 +119,16 @@ struct SourceDetailView: View {
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
// Delete and dismiss
let repository = InvestmentSourceRepository()
repository.deleteSource(viewModel.source)
viewModel.deleteSource()
dismiss()
}
} message: {
Text("This will permanently delete \(viewModel.source.name) and all its snapshots.")
Text("This will permanently delete \(viewModel.safeSourceName) and all its snapshots.")
}
.onChange(of: viewModel.isDeleted) { _, deleted in
if deleted {
dismiss()
}
}
}
@@ -426,25 +448,25 @@ struct SourceDetailView: View {
// Use LazyVStack for better performance with many snapshots
LazyVStack(spacing: 0) {
ForEach(viewModel.visibleSnapshots) { snapshot in
ForEach(viewModel.visibleSnapshots, id: \.objectID) { snapshot in
VStack(spacing: 0) {
SnapshotRowView(snapshot: snapshot, onEdit: {
editingSnapshot = snapshot
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
})
.contentShape(Rectangle())
.onTapGesture {
editingSnapshot = snapshot
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
}
.contextMenu {
Button {
editingSnapshot = snapshot
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
} label: {
Label("Edit", systemImage: "pencil")
}
}
.swipeActions(edge: .trailing) {
Button {
editingSnapshot = snapshot
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
} label: {
Label("Edit", systemImage: "pencil")
}
@@ -456,7 +478,7 @@ struct SourceDetailView: View {
}
}
if snapshot.id != viewModel.visibleSnapshots.last?.id {
if snapshot.objectID != viewModel.visibleSnapshots.last?.objectID {
Divider()
}
}
@@ -505,7 +527,7 @@ struct SnapshotRowView: View {
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.date.friendlyDescription)
Text(snapshot.safeDate.friendlyDescription)
.font(.subheadline)
if let notes = snapshot.notes, !notes.isEmpty {
@@ -591,6 +613,27 @@ struct MetricChip: View {
}
}
private struct SnapshotSelection: Identifiable {
let id: NSManagedObjectID
}
private struct MissingSnapshotView: View {
var body: some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 40))
.foregroundStyle(.orange)
Text("Snapshot not available")
.font(.headline)
Text("This snapshot was removed or is no longer accessible.")
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding()
}
}
#Preview {
NavigationStack {
Text("Source Detail Preview")
@@ -1,17 +1,21 @@
import SwiftUI
import CoreData
struct SourceListView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var accountStore: AccountStore
@Environment(\.managedObjectContext) private var context
@StateObject private var viewModel: SourceListViewModel
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@State private var sourceToDelete: InvestmentSource?
@State private var navigationPath = NavigationPath()
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
}
var body: some View {
NavigationStack {
NavigationStack(path: $navigationPath) {
ZStack {
AppBackground()
@@ -58,6 +62,36 @@ struct SourceListView: View {
.onReceive(accountStore.$showAllAccounts) { showAll in
viewModel.showAllAccounts = showAll
}
.navigationDestination(for: NSManagedObjectID.self) { objectID in
if let source = try? context.existingObject(with: objectID) as? InvestmentSource,
!source.isDeleted {
SourceDetailView(source: source, iapService: iapService)
} else {
MissingSourceView()
}
}
.confirmationDialog(
"Delete Source",
isPresented: Binding(
get: { sourceToDelete != nil },
set: { if !$0 { sourceToDelete = nil } }
),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let source = sourceToDelete {
viewModel.deleteSource(source)
sourceToDelete = nil
}
}
Button("Cancel", role: .cancel) {
sourceToDelete = nil
}
} message: {
if let source = sourceToDelete {
Text("This will permanently delete \(source.name) and all its snapshots.")
}
}
}
}
@@ -114,15 +148,19 @@ struct SourceListView: View {
// Sources
Section {
ForEach(viewModel.sources) { source in
NavigationLink {
SourceDetailView(source: source, iapService: iapService)
} label: {
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
}
}
.onDelete { indexSet in
viewModel.deleteSource(at: indexSet)
ForEach(viewModel.sources, id: \.objectID) { source in
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
.contentShape(Rectangle())
.onTapGesture {
navigationPath.append(source.objectID)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
sourceToDelete = source
} label: {
Label("Delete", systemImage: "trash")
}
}
}
} header: {
if viewModel.isFiltered {
@@ -263,6 +301,22 @@ struct SourceListView: View {
}
}
private struct MissingSourceView: View {
var body: some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 42))
.foregroundColor(.secondary)
Text("Source not available")
.font(.headline)
Text("This source was deleted.")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding()
}
}
// MARK: - Source Row View
struct SourceRowView: View {