Base fixes and test harness
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user