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
+1 -1
View File
@@ -166,7 +166,7 @@ struct ContentView: View {
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if !iapService.isPremium {
if !iapService.isPremium && adMobService.canShowAds {
BannerAdView()
.frame(height: AppConstants.UI.bannerAdHeight)
.frame(maxWidth: .infinity)
@@ -28,6 +28,14 @@ public class InvestmentSource: NSManagedObject, Identifiable {
customFrequencyMonths = 1
name = ""
}
public override func awakeFromFetch() {
super.awakeFromFetch()
// Defensive: ensure id exists for legacy rows where id may be nil.
if value(forKey: "id") == nil {
setValue(UUID(), forKey: "id")
}
}
}
// MARK: - Notification Frequency
@@ -21,11 +21,35 @@ public class Snapshot: NSManagedObject, Identifiable {
date = Date()
createdAt = Date()
}
public override func awakeFromFetch() {
super.awakeFromFetch()
// Defensive: ensure id exists for legacy rows where id may be nil.
if value(forKey: "id") == nil {
setValue(UUID(), forKey: "id")
}
}
}
// MARK: - Computed Properties
extension Snapshot {
var safeId: UUID {
if let existing = primitiveValue(forKey: "id") as? UUID {
return existing
}
let newId = UUID()
setPrimitiveValue(newId, forKey: "id")
return newId
}
var safeDate: Date {
if let dateValue = primitiveValue(forKey: "date") as? Date {
return dateValue
}
return Date()
}
var decimalValue: Decimal {
value?.decimalValue ?? Decimal.zero
}
@@ -162,7 +162,12 @@ class InvestmentSourceRepository: ObservableObject {
// MARK: - Delete
func deleteSource(_ source: InvestmentSource) {
context.delete(source)
if source.managedObjectContext == context {
context.delete(source)
} else {
let objectInContext = context.object(with: source.objectID)
context.delete(objectInContext)
}
save()
}
+97 -58
View File
@@ -2,6 +2,7 @@ import Foundation
import SwiftUI
import Combine
import GoogleMobileAds
import UserMessagingPlatform
import AppTrackingTransparency
import AdSupport
@@ -12,6 +13,7 @@ class AdMobService: ObservableObject {
@Published var isConsentObtained = false
@Published var canShowAds = false
@Published var isLoading = false
@Published var shouldRequestNonPersonalizedAds = false
// MARK: - Ad Unit IDs
@@ -25,76 +27,107 @@ class AdMobService: ObservableObject {
// MARK: - Initialization
init() {
checkConsentStatus()
setupResetObserver()
Task {
await configureConsentAndRequestAdsIfNeeded()
}
}
// MARK: - Consent Management
func checkConsentStatus() {
// Check if we already have consent
let consentStatus = UserDefaults.standard.bool(forKey: "adConsentObtained")
isConsentObtained = consentStatus
canShowAds = consentStatus
}
func requestConsent() async {
if #available(iOS 14.5, *) {
let status = await ATTrackingManager.requestTrackingAuthorization()
await configureConsentAndRequestAdsIfNeeded()
}
switch status {
case .authorized:
isConsentObtained = true
canShowAds = true
case .denied, .restricted:
// Can still show non-personalized ads
isConsentObtained = true
canShowAds = true
case .notDetermined:
// Will be asked again later
break
@unknown default:
break
func presentPrivacyOptions() async {
guard let root = topViewController() else { return }
await withCheckedContinuation { continuation in
ConsentForm.presentPrivacyOptionsForm(from: root) { _ in
continuation.resume()
}
} else {
// iOS 14.4 and earlier - consent assumed
isConsentObtained = true
canShowAds = true
}
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
updateConsentState()
}
// MARK: - GDPR Consent (UMP SDK)
func requestGDPRConsent() async {
// Implement UMP SDK consent flow if targeting EU users
// This is a simplified version - full implementation requires UMP SDK
let isEUUser = isUserInEU()
if isEUUser {
// Show GDPR consent dialog
// For now, assume consent if user continues
isConsentObtained = true
canShowAds = true
} else {
isConsentObtained = true
canShowAds = true
}
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
func resetConsent() {
ConsentInformation.shared.reset()
UserDefaults.standard.removeObject(forKey: AppConstants.StorageKeys.adConsentObtained)
isConsentObtained = false
canShowAds = false
shouldRequestNonPersonalizedAds = false
}
private func isUserInEU() -> Bool {
let euCountries = [
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "IS", "LI",
"NO", "CH"
]
private func setupResetObserver() {
NotificationCenter.default.addObserver(
forName: .didResetData,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.resetConsent()
await self.configureConsentAndRequestAdsIfNeeded()
}
}
}
let countryCode = Locale.current.region?.identifier ?? ""
return euCountries.contains(countryCode)
private func configureConsentAndRequestAdsIfNeeded() async {
isLoading = true
let parameters = RequestParameters()
await withCheckedContinuation { continuation in
ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { _ in
continuation.resume()
}
}
if let root = topViewController() {
await withCheckedContinuation { continuation in
ConsentForm.loadAndPresentIfRequired(from: root) { _ in
continuation.resume()
}
}
}
updateConsentState()
if canShowAds {
await requestTrackingIfNeeded()
updateConsentState()
}
isLoading = false
}
private func updateConsentState() {
let consentInfo = ConsentInformation.shared
canShowAds = consentInfo.canRequestAds
isConsentObtained = consentInfo.consentStatus == .obtained || consentInfo.consentStatus == .notRequired
let trackingDenied: Bool
if #available(iOS 14.5, *) {
trackingDenied = ATTrackingManager.trackingAuthorizationStatus != .authorized
} else {
trackingDenied = false
}
shouldRequestNonPersonalizedAds = !isConsentObtained || trackingDenied
UserDefaults.standard.set(isConsentObtained, forKey: AppConstants.StorageKeys.adConsentObtained)
}
private func requestTrackingIfNeeded() async {
if #available(iOS 14.5, *) {
_ = await ATTrackingManager.requestTrackingAuthorization()
}
}
private func topViewController() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else {
return nil
}
var current = root
while let presented = current.presentedViewController {
current = presented
}
return current
}
// MARK: - Analytics
@@ -159,7 +192,13 @@ struct BannerAdView: UIViewRepresentable {
}
bannerView.delegate = context.coordinator
bannerView.load(Request())
let request = Request()
if adMobService.shouldRequestNonPersonalizedAds {
let extras = Extras()
extras.additionalParameters = ["npa": "1"]
request.register(extras)
}
bannerView.load(request)
return bannerView
}
@@ -376,7 +376,7 @@ class CalculationService {
sources: [InvestmentSource],
totalPortfolioValue: Decimal
) -> [CategoryMetrics] {
categories.map { category in
let rawMetrics = categories.map { category in
let categorySources = sources.filter { $0.category?.id == category.id }
let allSnapshots = categorySources.flatMap { $0.snapshotsArray }
let metrics = calculateCategoryMetrics(from: allSnapshots)
@@ -396,6 +396,24 @@ class CalculationService {
metrics: metrics
)
}
let filtered = rawMetrics.filter { metric in
metric.totalValue > 0 && sources.contains { $0.category?.id == metric.id }
}
var deduped: [String: CategoryMetrics] = [:]
for metric in filtered {
let key = metric.categoryName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if let existing = deduped[key] {
if metric.totalValue > existing.totalValue {
deduped[key] = metric
}
} else {
deduped[key] = metric
}
}
return Array(deduped.values)
}
private struct SeriesPoint {
@@ -6,6 +6,16 @@ enum CurrencyFormatter {
return AppSettings.getOrCreate(in: context).currency
}
static func locale(for currencyCode: String?) -> Locale {
guard let currencyCode, !currencyCode.isEmpty else { return Locale.current }
if let match = Locale.availableIdentifiers.first(where: {
Locale(identifier: $0).currency?.identifier == currencyCode
}) {
return Locale(identifier: match)
}
return Locale.current
}
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = style
@@ -14,6 +24,21 @@ enum CurrencyFormatter {
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
}
static func format(
_ decimal: Decimal,
currencyCode: String?,
style: NumberFormatter.Style = .currency,
maximumFractionDigits: Int = 2,
preferredLocale: Locale? = nil
) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = style
formatter.currencyCode = currencyCode ?? currentCurrencyCode()
formatter.maximumFractionDigits = maximumFractionDigits
formatter.locale = preferredLocale ?? locale(for: currencyCode)
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
}
static func symbol(for code: String) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
@@ -74,9 +74,39 @@ enum MonthlyCheckInStore {
}
static func setCompletionDate(_ completionDate: Date, for month: Date) {
updateEntry(for: month) { entry in
entry.completionTime = completionDate.timeIntervalSince1970
let targetMonth = month.startOfMonth
let targetKey = monthKey(for: targetMonth)
var entries = loadEntries()
var didChange = false
// Ensure the target month entry exists.
var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: legacyCompletion(for: targetKey),
createdAt: Date().timeIntervalSince1970
)
targetEntry.completionTime = completionDate.timeIntervalSince1970
entries[targetKey] = targetEntry
didChange = true
// Mark all previous pending entries as completed as well.
for (key, entry) in entries {
guard let entryMonth = monthFormatter.date(from: key)?.startOfMonth else { continue }
guard entryMonth < targetMonth else { continue }
guard entry.completionTime == nil else { continue }
var updated = entry
let fallbackCompletion = min(entryMonth.endOfMonth, completionDate)
updated.completionTime = fallbackCompletion.timeIntervalSince1970
entries[key] = updated
didChange = true
}
guard didChange else { return }
saveEntries(entries)
persistLegacyMirrors(entries)
}
static func latestCompletionDate() -> Date? {
@@ -530,7 +530,7 @@ class ChartsViewModel: ObservableObject {
snapshots: [Snapshot]
) -> [Snapshot] {
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
return []
return snapshots
}
let completedMonthsAfter = completedMonthKeys(
@@ -541,6 +541,10 @@ class ChartsViewModel: ObservableObject {
return snapshots.filter { snapshot in
let monthDate = snapshot.date.startOfMonth
if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate),
snapshot.date > completionDate {
return false
}
if monthDate <= lastCompleted {
return true
}
@@ -195,7 +195,11 @@ class DashboardViewModel: ObservableObject {
snapshots: allSnapshots
)
updateEvolutionData(from: completedSnapshots, categories: categories)
latestPortfolioChange = calculateLatestChange(from: evolutionData)
latestPortfolioChange = calculateLatestCheckInChange(
sources: sources,
snapshots: allSnapshots,
fallback: evolutionData
)
// Calculate portfolio forecast
updatePortfolioForecast()
@@ -360,7 +364,7 @@ class DashboardViewModel: ObservableObject {
snapshots: [Snapshot]
) -> [Snapshot] {
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
return []
return snapshots
}
let completedMonthsAfter = completedMonthKeys(
@@ -371,6 +375,10 @@ class DashboardViewModel: ObservableObject {
return snapshots.filter { snapshot in
let monthDate = snapshot.date.startOfMonth
if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate),
snapshot.date > completionDate {
return false
}
if monthDate <= lastCompleted {
return true
}
@@ -382,7 +390,7 @@ class DashboardViewModel: ObservableObject {
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
guard data.count >= 2 else {
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
return PortfolioChange(absolute: 0, percentage: 0, label: "since last check-in")
}
let last = data[data.count - 1]
let previous = data[data.count - 2]
@@ -390,7 +398,58 @@ class DashboardViewModel: ObservableObject {
let percentage = previous.value > 0
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
: 0
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in")
}
private func calculateLatestCheckInChange(
sources: [InvestmentSource],
snapshots: [Snapshot],
fallback: [(date: Date, value: Decimal)]
) -> PortfolioChange {
let completedMonths = MonthlyCheckInStore.allEntries()
.compactMap { entry -> Date? in
entry.entry.completionDate != nil ? entry.date.startOfMonth : nil
}
.sorted()
guard completedMonths.count >= 2 else {
return calculateLatestChange(from: fallback)
}
let lastMonth = completedMonths[completedMonths.count - 1]
let previousMonth = completedMonths[completedMonths.count - 2]
let lastCompletion = MonthlyCheckInStore.completionDate(for: lastMonth) ?? lastMonth.endOfMonth
let previousCompletion = MonthlyCheckInStore.completionDate(for: previousMonth) ?? previousMonth.endOfMonth
let lastValue = totalPortfolioValue(asOf: lastCompletion, sources: sources, snapshots: snapshots)
let previousValue = totalPortfolioValue(asOf: previousCompletion, sources: sources, snapshots: snapshots)
let absolute = lastValue - previousValue
let percentage = previousValue > 0
? NSDecimalNumber(decimal: absolute / previousValue).doubleValue * 100
: 0
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in")
}
private func totalPortfolioValue(
asOf date: Date,
sources: [InvestmentSource],
snapshots: [Snapshot]
) -> Decimal {
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
var total = Decimal.zero
for source in sources {
let sourceId = source.id
guard let sourceSnapshots = snapshotsBySource[sourceId] else { continue }
if let latest = sourceSnapshots
.filter({ $0.date <= date })
.max(by: { $0.date < $1.date }) {
total += latest.decimalValue
}
}
return total
}
private func updatePortfolioForecast() {
@@ -12,6 +12,7 @@ class SnapshotFormViewModel: ObservableObject {
@Published var includeContribution = false
@Published var inputMode: InputMode = .simple
@Published var currencySymbol = ""
private let currencyCode: String
@Published var isValid = false
@Published var errorMessage: String?
@@ -37,8 +38,10 @@ class SnapshotFormViewModel: ObservableObject {
self.mode = mode
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
currencyCode = accountCurrency
currencySymbol = CurrencyFormatter.symbol(for: accountCurrency)
} else {
currencyCode = settings.currency
currencySymbol = settings.currencySymbol
}
if let accountMode = InputMode(rawValue: source.account?.inputMode ?? "") {
@@ -99,23 +102,49 @@ class SnapshotFormViewModel: ObservableObject {
// MARK: - Parsing
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 decimalSeparator = locale.decimalSeparator ?? "."
let usesAlternateDecimal =
(decimalSeparator == "," && cleaned.contains(".") && !cleaned.contains(",")) ||
(decimalSeparator == "." && cleaned.contains(",") && !cleaned.contains("."))
if usesAlternateDecimal {
let normalized = cleaned
.replacingOccurrences(of: ",", with: ".")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US")
formatter.locale = locale
return formatter.number(from: cleaned)?.decimalValue
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: ".")
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 = 2
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ""
@@ -12,6 +12,7 @@ class SourceDetailViewModel: ObservableObject {
@Published var predictions: [Prediction] = []
@Published var predictionResult: PredictionResult?
@Published var transactions: [Transaction] = []
@Published var isDeleted = false
@Published var isLoading = false
@Published var showingAddSnapshot = false
@@ -37,6 +38,7 @@ class SourceDetailViewModel: ObservableObject {
private var isRefreshing = false
private var refreshQueued = false
private var refreshTask: Task<Void, Never>?
private let sourceName: String
// MARK: - Initialization
@@ -50,6 +52,7 @@ class SourceDetailViewModel: ObservableObject {
iapService: IAPService
) {
self.source = source
self.sourceName = source.name
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.transactionRepository = transactionRepository ?? TransactionRepository()
@@ -103,6 +106,11 @@ class SourceDetailViewModel: ObservableObject {
refreshTask = Task { [weak self] in
guard let self else { return }
guard !self.isDeleted, !self.source.isDeleted, self.source.managedObjectContext != nil else {
self.isDeleted = true
self.isRefreshing = false
return
}
while self.refreshQueued && !Task.isCancelled {
self.refreshQueued = false
@@ -232,6 +240,23 @@ class SourceDetailViewModel: ObservableObject {
showingEditSource = false
}
func deleteSource() {
guard !isDeleted else { return }
// Mark deleted first so the view can dismiss before any further access.
isDeleted = true
snapshots = []
chartData = []
predictionResult = nil
// Cancel any pending notifications for this source
NotificationService.shared.cancelReminder(for: source)
// Log analytics before deletion
FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized")
// Delete the source using the existing repository
sourceRepository.deleteSource(source)
}
// MARK: - Predictions
func showPredictions() {
@@ -256,6 +281,13 @@ class SourceDetailViewModel: ObservableObject {
currentValue.currencyString
}
var safeSourceName: String {
if source.isDeleted || source.managedObjectContext == nil {
return sourceName
}
return source.name
}
var totalReturn: Decimal {
metrics.absoluteReturn
}
@@ -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 {