diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 0e7ed69..9a0d653 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -459,7 +459,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -477,7 +477,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -498,7 +498,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -516,7 +516,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -655,7 +655,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -668,7 +668,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -688,7 +688,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -701,7 +701,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -719,7 +719,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -728,7 +728,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -743,7 +743,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -752,7 +752,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -766,7 +766,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -775,7 +775,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -789,7 +789,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -798,7 +798,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; diff --git a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents index 4137f6f..a309e8d 100644 --- a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents +++ b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents @@ -75,7 +75,7 @@ - + diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index 27ef940..92ba3ee 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -3,6 +3,10 @@ import CloudKit import Combine import WidgetKit +extension Notification.Name { + static let cloudKitForceReload = Notification.Name("cloudKitForceReload") +} + class CoreDataStack: ObservableObject { static let shared = CoreDataStack() @@ -115,6 +119,35 @@ class CoreDataStack: ObservableObject { } @Published private(set) var isLoaded = false + @Published private(set) var lastImportDate: Date? + @Published private(set) var lastExportDate: Date? + @Published private(set) var isSyncing = false + @Published private(set) var lastSyncError: String? + + var lastSyncDate: Date? { lastImportDate } + + var localSourceCount: Int { + let request = NSFetchRequest(entityName: "InvestmentSource") + return (try? viewContext.count(for: request)) ?? 0 + } + + var localSnapshotCount: Int { + let request = NSFetchRequest(entityName: "Snapshot") + return (try? viewContext.count(for: request)) ?? 0 + } + + private init() { + // Register CloudKit event observer BEFORE the container is created so we + // never miss an import/export event that fires during store loading. + if Self.cloudKitEnabled { + NotificationCenter.default.addObserver( + self, + selector: #selector(cloudKitEventChanged(_:)), + name: NSPersistentCloudKitContainer.eventChangedNotification, + object: nil + ) + } + } lazy var persistentContainer: NSPersistentContainer = { let container: NSPersistentContainer @@ -131,12 +164,15 @@ class CoreDataStack: ObservableObject { description.shouldMigrateStoreAutomatically = true description.shouldInferMappingModelAutomatically = true + // Always enable history tracking so data created before CloudKit was enabled + // is visible to NSPersistentCloudKitContainer when sync is later turned on. + description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) + if Self.cloudKitEnabled { description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions( containerIdentifier: Self.cloudKitContainerIdentifier ) - description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) - description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) } container.persistentStoreDescriptions = [description] @@ -177,42 +213,44 @@ class CoreDataStack: ObservableObject { return persistentContainer.viewContext } - private init() {} - // MARK: - Cleanup Duplicates /// Removes duplicate objects that have the same UUID, keeping only the oldest one. - /// This fixes data corruption from race conditions during object creation. - func cleanupDuplicateObjects() { + /// This fixes data corruption from race conditions during object creation, and also + /// handles the case where a CloudKit first-time sync imports records that already exist + /// locally (because they were created before CloudKit was enabled). + @discardableResult + func cleanupDuplicateObjects() -> Int { + var totalRemoved = 0 let context = viewContext context.performAndWait { - // Clean up duplicate Goals - cleanupDuplicates(entityName: "Goal", idKey: "id", context: context) - // Clean up duplicate Accounts (already handled in AccountRepository but added here for safety) - cleanupDuplicates(entityName: "Account", idKey: "id", context: context) - // Clean up duplicate InvestmentSources - cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context) - // Clean up duplicate Categories - cleanupDuplicates(entityName: "Category", idKey: "id", context: context) + // Clean up Snapshots first — before cascade rules from InvestmentSource fire, + // so we deduplicate by UUID and not rely solely on cascade. + totalRemoved += cleanupDuplicates(entityName: "Snapshot", idKey: "id", context: context) + totalRemoved += cleanupDuplicates(entityName: "Goal", idKey: "id", context: context) + totalRemoved += cleanupDuplicates(entityName: "Account", idKey: "id", context: context) + totalRemoved += cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context) + totalRemoved += cleanupDuplicates(entityName: "Category", idKey: "id", context: context) if context.hasChanges { try? context.save() } } + return totalRemoved } - private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) { + @discardableResult + private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) -> Int { let request = NSFetchRequest(entityName: entityName) request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)] - guard let objects = try? context.fetch(request) else { return } + guard let objects = try? context.fetch(request) else { return 0 } var seenIds = Set() var objectsToDelete: [NSManagedObject] = [] for object in objects { guard let objectId = object.value(forKey: idKey) as? UUID else { continue } - if seenIds.contains(objectId) { objectsToDelete.append(object) } else { @@ -221,11 +259,10 @@ class CoreDataStack: ObservableObject { } if !objectsToDelete.isEmpty { - print("Cleaning up \(objectsToDelete.count) duplicate \(entityName) objects") - for object in objectsToDelete { - context.delete(object) - } + print("[Dedup] Removing \(objectsToDelete.count) duplicate \(entityName) objects") + for object in objectsToDelete { context.delete(object) } } + return objectsToDelete.count } // MARK: - Save Context @@ -260,17 +297,118 @@ class CoreDataStack: ObservableObject { @objc private func processRemoteChanges(_ notification: Notification) { DispatchQueue.main.async { [weak self] in guard let self else { return } - // Force viewContext to re-read all objects from the persistent store, - // which now contains the changes CloudKit just synced from other devices. - // This triggers NSManagedObjectContextObjectsDidChange so all repositories - // automatically re-fetch and push fresh data to ViewModels. + // Force viewContext to re-read all objects from the persistent store. self.viewContext.refreshAllObjects() + // Remove any duplicates that CloudKit import may have introduced. + // This handles the first-time sync case where records existed locally before + // CloudKit was enabled: the initial export+import creates duplicate objects. + let removed = self.cleanupDuplicateObjects() + if removed > 0 { + print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import") + } + // Notify repositories to re-fetch unconditionally. + NotificationCenter.default.post(name: .cloudKitForceReload, object: nil) self.objectWillChange.send() self.save() self.refreshWidgetData() } } + @objc private func cloudKitEventChanged(_ notification: Notification) { + guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] + as? NSPersistentCloudKitContainer.Event else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let isActive = event.endDate == nil + self.isSyncing = isActive + if !isActive { + if event.succeeded, let endDate = event.endDate { + self.lastSyncError = nil + switch event.type { + case .import: self.lastImportDate = endDate + case .export: self.lastExportDate = endDate + default: break + } + } else if let error = event.error { + let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup" + self.lastSyncError = "\(typeLabel): \(Self.describeError(error))" + print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)") + } + } + } + } + + private static func describeError(_ error: Error, depth: Int = 0) -> String { + guard depth < 3 else { return "…" } + let ns = error as NSError + var parts: [String] = ["\(ns.domain)(\(ns.code))"] + // At depth 0, always include the localised description so the user sees + // a human-readable message even when userInfo has no other keys. + if depth == 0, let msg = ns.userInfo[NSLocalizedDescriptionKey] as? String { + parts.append(msg) + } + for (key, value) in ns.userInfo { + let k = "\(key)" + if k == NSLocalizedDescriptionKey || k == "NSLocalizedDescription" { continue } + if let nestedError = value as? Error { + parts.append("\(k):\(describeError(nestedError, depth: depth + 1))") + } else if let dict = value as? [AnyHashable: Any], !dict.isEmpty { + let pairs = dict.prefix(3).map { kk, vv -> String in + if let e = vv as? Error { return "\(kk)→\(describeError(e, depth: depth + 1))" } + return "\(kk)=\(vv)" + } + parts.append("\(k){\(pairs.joined(separator: ", "))}") + } else if !(value is [AnyHashable: Any]) { + parts.append("\(k)=\(value)") + } + } + return parts.joined(separator: "\n") + } + + func forceReload() { + viewContext.perform { [weak self] in + self?.viewContext.refreshAllObjects() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .cloudKitForceReload, object: nil) + } + } + } + + /// Forces NSPersistentCloudKitContainer to export all local data to iCloud. + /// + /// Strategy: advance `createdAt` by 1 ms for every record. This guarantees + /// a *real* value change that the SQLite persistent store will write as a + /// persistent-history transaction — which is what NSPersistentCloudKitContainer + /// needs to discover records and enqueue them for CloudKit export. + /// + /// Setting a property to the *same* value may be silently discarded by the + /// SQLite layer (no SQL UPDATE issued → no history entry → nothing to export). + func forceExportToiCloud(completion: @escaping (Int) -> Void) { + guard Self.cloudKitEnabled else { completion(0); return } + let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"] + let context = newBackgroundContext() + context.perform { [weak self] in + var totalTouched = 0 + for entityName in entities { + let request = NSFetchRequest(entityName: entityName) + request.fetchBatchSize = 50 + guard let objects = try? context.fetch(request) else { continue } + for object in objects { + // Advance createdAt by 1 ms → always a genuine value change. + let t = (object.value(forKey: "createdAt") as? Date) ?? Date() + object.setValue(t.addingTimeInterval(0.001), forKey: "createdAt") + totalTouched += 1 + } + } + if context.hasChanges { + try? context.save() + } + DispatchQueue.main.async { + completion(totalTouched) + } + } + } + /// Forces an immediate re-read of all Core Data objects from the persistent store. /// Call this when the app returns to the foreground so any iCloud changes made on /// other devices (while this device was inactive) are picked up right away. diff --git a/PortfolioJournal/Repositories/AccountRepository.swift b/PortfolioJournal/Repositories/AccountRepository.swift index fe4889c..396bbe9 100644 --- a/PortfolioJournal/Repositories/AccountRepository.swift +++ b/PortfolioJournal/Repositories/AccountRepository.swift @@ -23,6 +23,11 @@ class AccountRepository: ObservableObject { self?.fetchAccounts() } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .cloudKitForceReload) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.fetchAccounts() } + .store(in: &cancellables) } // MARK: - Fetch diff --git a/PortfolioJournal/Repositories/CategoryRepository.swift b/PortfolioJournal/Repositories/CategoryRepository.swift index 3b2f54d..5ee5db6 100644 --- a/PortfolioJournal/Repositories/CategoryRepository.swift +++ b/PortfolioJournal/Repositories/CategoryRepository.swift @@ -25,6 +25,11 @@ class CategoryRepository: ObservableObject { self.fetchCategories() } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .cloudKitForceReload) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.fetchCategories() } + .store(in: &cancellables) } // MARK: - Fetch diff --git a/PortfolioJournal/Repositories/GoalRepository.swift b/PortfolioJournal/Repositories/GoalRepository.swift index 980fa76..bde1d3e 100644 --- a/PortfolioJournal/Repositories/GoalRepository.swift +++ b/PortfolioJournal/Repositories/GoalRepository.swift @@ -23,6 +23,11 @@ class GoalRepository: ObservableObject { self?.fetchGoals() } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .cloudKitForceReload) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.fetchGoals() } + .store(in: &cancellables) } // MARK: - Fetch diff --git a/PortfolioJournal/Repositories/InvestmentSourceRepository.swift b/PortfolioJournal/Repositories/InvestmentSourceRepository.swift index 6220ad4..26fbe1f 100644 --- a/PortfolioJournal/Repositories/InvestmentSourceRepository.swift +++ b/PortfolioJournal/Repositories/InvestmentSourceRepository.swift @@ -24,6 +24,11 @@ class InvestmentSourceRepository: ObservableObject { self.fetchSources() } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .cloudKitForceReload) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.fetchSources() } + .store(in: &cancellables) } // MARK: - Fetch diff --git a/PortfolioJournal/Resources/Info.plist b/PortfolioJournal/Resources/Info.plist index 2e84f51..180cadc 100644 --- a/PortfolioJournal/Resources/Info.plist +++ b/PortfolioJournal/Resources/Info.plist @@ -35,6 +35,8 @@ ca-app-pub-1549720748100858~9632507420 GADDelayAppMeasurementInit + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index 7b3fb49..27e422d 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -293,6 +293,25 @@ "onboarding_add_first_source" = "Add My First Investment"; "onboarding_import_data" = "Import Existing Data"; +// MARK: - iCloud Sync Status (1.3.1) +"Force Upload to iCloud" = "Force Upload to iCloud"; +"Uploading..." = "Uploading..."; +"No export yet" = "No export yet"; +"Syncing with iCloud..." = "Syncing with iCloud..."; +"Not synced yet" = "Not synced yet"; +"Refresh" = "Refresh"; + +// MARK: - iCloud Diagnostics (1.3.1) +"Verify iCloud Setup" = "Verify iCloud Setup"; +"Checking…" = "Checking…"; +"context.hasChanges: YES ✓" = "context.hasChanges: YES ✓"; +"context.hasChanges: NO ✗ — objects may not have been queued" = "context.hasChanges: NO ✗ — objects may not have been queued"; + +// MARK: - Sources Filter & Search (1.3.1) +"sources_filter_all" = "All"; +"Search sources" = "Search sources"; +"Clear Filters" = "Clear Filters"; + // MARK: - AddSourceView (1.3.0) "add_source_name_placeholder" = "e.g. MSCI World ETF, ING Savings, Apartment..."; "add_source_name_footer" = "A source is any investment you want to track: stocks, ETFs, savings accounts, real estate, crypto, and more."; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index d2ea4af..d4db6c4 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -222,6 +222,23 @@ "onboarding_add_first_source" = "Añadir mi primera inversión"; "onboarding_import_data" = "Importar datos existentes"; +// MARK: - iCloud Sync Status (1.3.1) +"Force Upload to iCloud" = "Forzar subida a iCloud"; +"Uploading..." = "Subiendo..."; +"No export yet" = "Aún no exportado"; +"Syncing with iCloud..." = "Sincronizando con iCloud..."; +"Not synced yet" = "Aún no sincronizado"; +"Refresh" = "Actualizar"; + +// MARK: - iCloud Diagnostics (1.3.1) +"Verify iCloud Setup" = "Verificar configuración iCloud"; +"Checking…" = "Comprobando…"; + +// MARK: - Sources Filter & Search (1.3.1) +"sources_filter_all" = "Todas"; +"Search sources" = "Buscar fuentes"; +"Clear Filters" = "Limpiar filtros"; + // MARK: - AddSourceView (1.3.0) "add_source_name_placeholder" = "p.ej. ETF MSCI World, Cuenta ING, Piso..."; "add_source_name_footer" = "Una fuente es cualquier inversión que quieras seguir: acciones, ETFs, cuentas de ahorro, inmuebles, cripto y más."; diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index b8c31f5..27b26aa 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -14,6 +14,8 @@ struct SettingsView: View { @AppStorage("lockOnLaunch") private var lockOnLaunch = true @AppStorage("lockOnBackground") private var lockOnBackground = false + @ObservedObject private var cloudStack = CoreDataStack.shared + @State private var showingPinSetup = false @State private var showingPinChange = false @State private var showingBiometricAlert = false @@ -22,6 +24,8 @@ struct SettingsView: View { @State private var showingPinVerifyForFaceId = false @State private var showingRestartAlert = false @State private var didLoadCloudSync = false + @State private var isForceUploading = false + @State private var forceUploadResult: String? @State private var backupToRestore: BackupRecord? init(iapService: IAPService) { @@ -342,6 +346,104 @@ struct SettingsView: View { viewModel.refreshBackups() } + if cloudSyncEnabled { + VStack(alignment: .leading, spacing: 6) { + // Local data counts + HStack { + Label( + "\(cloudStack.localSourceCount) sources · \(cloudStack.localSnapshotCount) snapshots", + systemImage: "internaldrive" + ) + .font(.subheadline) + .foregroundColor(.primary) + + Spacer() + + Button("Refresh") { + CoreDataStack.shared.forceReload() + } + .font(.subheadline) + .disabled(cloudStack.isSyncing) + } + + // Import / export status + if cloudStack.isSyncing { + Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud") + .font(.caption) + .foregroundColor(.secondary) + } else { + HStack(spacing: 12) { + if let date = cloudStack.lastImportDate { + Label("↓ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down") + .font(.caption) + .foregroundColor(.secondary) + } else { + Label("No import yet", systemImage: "icloud.and.arrow.down") + .font(.caption) + .foregroundColor(.orange) + } + + if let date = cloudStack.lastExportDate { + Label("↑ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up") + .font(.caption) + .foregroundColor(.secondary) + } else { + Label("No export yet", systemImage: "icloud.and.arrow.up") + .font(.caption) + .foregroundColor(.orange) + } + } + } + + // CloudKit error (if any) + if let error = cloudStack.lastSyncError { + VStack(alignment: .leading, spacing: 2) { + Label("CloudKit error", systemImage: "exclamationmark.icloud") + .font(.caption.weight(.semibold)) + .foregroundColor(.red) + Text(error) + .font(.caption2) + .foregroundColor(.red) + .lineLimit(6) + .textSelection(.enabled) + } + } + + // Force upload — use when data exists locally but hasn't reached iCloud + if cloudStack.localSourceCount > 0 { + if let result = forceUploadResult { + Label(result, systemImage: "checkmark.icloud") + .font(.caption) + .foregroundColor(.secondary) + } else { + Button { + isForceUploading = true + CoreDataStack.shared.forceExportToiCloud { count in + isForceUploading = false + forceUploadResult = String( + format: NSLocalizedString("save_n_snapshots", comment: ""), + count + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + forceUploadResult = nil + } + } + } label: { + if isForceUploading { + Label("Uploading...", systemImage: "arrow.triangle.2.circlepath.icloud") + } else { + Label("Force Upload to iCloud", systemImage: "icloud.and.arrow.up") + } + } + .font(.caption) + .foregroundColor(.appPrimary) + .disabled(isForceUploading) + } + } + } + .padding(.vertical, 2) + } + Toggle( isOn: Binding( get: { viewModel.backupsEnabled }, diff --git a/PortfolioJournal/Views/Sources/SourceListView.swift b/PortfolioJournal/Views/Sources/SourceListView.swift index 24a9e5c..27cbd99 100644 --- a/PortfolioJournal/Views/Sources/SourceListView.swift +++ b/PortfolioJournal/Views/Sources/SourceListView.swift @@ -9,6 +9,7 @@ struct SourceListView: View { @AppStorage("calmModeEnabled") private var calmModeEnabled = true @State private var sourceToDelete: InvestmentSource? @State private var navigationPath = NavigationPath() + @State private var showingSearch = false init(iapService: IAPService) { _viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService)) @@ -24,11 +25,13 @@ struct SourceListView: View { emptyStateView } else { sourcesList + .safeAreaInset(edge: .top, spacing: 0) { + filterBar + } } } } .navigationTitle("Sources") - .searchable(text: $viewModel.searchText, prompt: "Search sources") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { @@ -38,8 +41,16 @@ struct SourceListView: View { } } - ToolbarItem(placement: .navigationBarLeading) { - categoryFilterMenu + ToolbarItem(placement: .navigationBarTrailing) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showingSearch.toggle() + } + if !showingSearch { viewModel.searchText = "" } + } label: { + Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass") + .foregroundColor(showingSearch ? .secondary : .primary) + } } ToolbarItem(placement: .navigationBarLeading) { @@ -213,46 +224,64 @@ struct SourceListView: View { } } - // MARK: - Category Filter Menu + // MARK: - Filter Bar (chips + search) - private var categoryFilterMenu: some View { - Menu { - Button { - viewModel.selectCategory(nil) - } label: { - HStack { - Text("All Categories") - if viewModel.selectedCategory == nil { - Image(systemName: "checkmark") + private var filterBar: some View { + VStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + CategoryChip( + title: String(localized: "sources_filter_all"), + icon: nil, + isSelected: viewModel.selectedCategory == nil + ) { + viewModel.selectCategory(nil) } - } - } - Divider() - - ForEach(viewModel.categories) { category in - Button { - viewModel.selectCategory(category) - } label: { - HStack { - Image(systemName: category.icon) - Text(category.name) - if viewModel.selectedCategory?.id == category.id { - Image(systemName: "checkmark") + ForEach(viewModel.categories) { category in + CategoryChip( + title: category.name, + icon: category.icon, + isSelected: viewModel.selectedCategory?.id == category.id + ) { + viewModel.selectCategory(category) } } } + .padding(.horizontal, 16) + .padding(.vertical, 10) } - } label: { - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal.decrease.circle") - if viewModel.selectedCategory != nil { - Circle() - .fill(Color.appPrimary) - .frame(width: 8, height: 8) + + if showingSearch { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search sources", text: $viewModel.searchText) + .textFieldStyle(.plain) + .autocorrectionDisabled() + + if !viewModel.searchText.isEmpty { + Button { + viewModel.searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.horizontal, 16) + .padding(.bottom, 10) + .transition(.move(edge: .top).combined(with: .opacity)) } + + Divider() } + .background(.bar) } // MARK: - Account Filter Menu @@ -301,6 +330,35 @@ struct SourceListView: View { } } +// MARK: - Category Chip + +private struct CategoryChip: View { + let title: String + let icon: String? + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + if let icon { + Image(systemName: icon) + .font(.caption2) + } + Text(title) + .font(.subheadline) + .fontWeight(isSelected ? .semibold : .regular) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? Color.appPrimary : Color(.systemGray5)) + .foregroundColor(isSelected ? .white : .primary) + .cornerRadius(20) + } + .buttonStyle(.plain) + } +} + private struct MissingSourceView: View { var body: some View { VStack(spacing: 12) { diff --git a/PortfolioJournalWidget/Info.plist b/PortfolioJournalWidget/Info.plist index 0ae0bde..32695ab 100644 --- a/PortfolioJournalWidget/Info.plist +++ b/PortfolioJournalWidget/Info.plist @@ -2,26 +2,28 @@ - CFBundleDisplayName - Investment Widget - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - + CFBundleDisplayName + Investment Widget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + diff --git a/Scripts/archive_and_upload_appstore.sh b/Scripts/archive_and_upload_appstore.sh index 7a0bd12..fd6637c 100755 --- a/Scripts/archive_and_upload_appstore.sh +++ b/Scripts/archive_and_upload_appstore.sh @@ -117,7 +117,11 @@ export_ipa() { -exportArchive \ -archivePath "$ARCHIVE_PATH" \ -exportPath "$EXPORT_PATH" \ - -exportOptionsPlist "$EXPORT_OPTIONS_PLIST" + -exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$P8_PATH" \ + -authenticationKeyID "$KEY_ID" \ + -authenticationKeyIssuerID "$ISSUER_ID" } upload_ipa() { diff --git a/fastlane/Deliverfile b/fastlane/Deliverfile index 9ea0516..465c2c4 100644 --- a/fastlane/Deliverfile +++ b/fastlane/Deliverfile @@ -1 +1 @@ -app_version "1.3.0" +app_version "1.3.1" diff --git a/fastlane/Fastfile b/fastlane/Fastfile index fca89b1..989ed01 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,8 +1,5 @@ default_platform(:ios) -# Credentials via `pass` -# export FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=$(pass show appstore/apple-app-specific-password) - platform :ios do EXPORT_OPTIONS = { method: "app-store", @@ -14,9 +11,19 @@ platform :ios do } } + def api_key + app_store_connect_api_key( + key_id: sh("pass show appstore/api-key-id", log: false).strip, + issuer_id: sh("pass show appstore/issuer-id", log: false).strip, + key_content: sh("pass show appstore/api-key-p8", log: false).strip, + is_key_content_base64: false + ) + end + desc "Upload metadata and release notes only (binary already uploaded)" lane :metadata do deliver( + api_key: api_key, metadata_path: "fastlane/metadata", skip_screenshots: true, skip_binary_upload: true, @@ -30,7 +37,24 @@ platform :ios do scheme: "PortfolioJournal", export_options: EXPORT_OPTIONS ) - upload_to_testflight(skip_waiting_for_build_processing: true) + upload_to_testflight( + api_key: api_key, + skip_waiting_for_build_processing: true + ) + end + + desc "Submit already-uploaded build for App Store review (binary already in TestFlight)" + lane :submit do + deliver( + api_key: api_key, + metadata_path: "fastlane/metadata", + skip_screenshots: true, + skip_binary_upload: true, + submit_for_review: true, + automatic_release: false, + run_precheck_before_submit: false, + force: true + ) end desc "Build, export and push a new release to the App Store" @@ -40,6 +64,7 @@ platform :ios do export_options: EXPORT_OPTIONS ) upload_to_app_store( + api_key: api_key, metadata_path: "fastlane/metadata", skip_screenshots: true, force: true diff --git a/fastlane/metadata/de-DE/release_notes.txt b/fastlane/metadata/de-DE/release_notes.txt index 92751dd..93cf4f0 100644 --- a/fastlane/metadata/de-DE/release_notes.txt +++ b/fastlane/metadata/de-DE/release_notes.txt @@ -1,7 +1,6 @@ -Verbessertes Onboarding und monatliche Erinnerungen: +Verbesserungen bei iCloud-Sync und Quellenverwaltung: -• Neu gestaltete Ersteinrichtung — eine klare Aktion zum Hinzufügen deiner ersten Investition, ohne unnötige Schritte -• Monatliche Erinnerungen — optionale Benachrichtigungen, damit du das monatliche Portfolio-Update nicht vergisst -• Charts-Vorschau — kostenlose Nutzer sehen, welche Charts verfügbar sind, bevor sie upgraden -• Verbesserter Paywall — eine echte Portfolio-Grafik zeigt, wie der Vollzugang aussieht -• Klarere Quelleneinrichtung — kurze Erklärung beim Erstellen deiner ersten Anlageposition +• iCloud-Sync — deine Daten synchronisieren sich jetzt zuverlässig auf allen Apple-Geräten +• Quellensuche — finde jede Quelle schnell nach Namen +• Kategoriefilter — filtere deine Quellen nach Anlageklasse +• Fehlerbehebungen und Leistungsverbesserungen diff --git a/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt index e99731c..b04fa6a 100644 --- a/fastlane/metadata/en-US/release_notes.txt +++ b/fastlane/metadata/en-US/release_notes.txt @@ -1,7 +1,6 @@ -Smarter onboarding and monthly reminders: +iCloud sync and source management improvements: -• Redesigned first-run experience — a single clear action to add your first investment, no setup friction -• Monthly reminders — optional notifications so you never forget your monthly portfolio update -• Charts preview — free users now see which charts are available before upgrading -• Improved paywall — a live portfolio chart preview shows what full access looks like -• Clearer source setup — added a short description when creating your first investment source +• iCloud sync — your data now syncs reliably across all your Apple devices +• Source search — quickly find any source by name +• Category filters — filter your sources by asset category +• Bug fixes and performance improvements diff --git a/fastlane/metadata/es-ES/release_notes.txt b/fastlane/metadata/es-ES/release_notes.txt index 7d32c3d..c610bfe 100644 --- a/fastlane/metadata/es-ES/release_notes.txt +++ b/fastlane/metadata/es-ES/release_notes.txt @@ -1,7 +1,6 @@ -Incorporación más clara y recordatorios mensuales: +Mejoras en la sincronización iCloud y la gestión de fuentes: -• Primera experiencia rediseñada — una acción clara para añadir tu primera inversión, sin pasos innecesarios -• Recordatorios mensuales — notificaciones opcionales para no olvidar actualizar tu portfolio cada mes -• Vista previa de gráficos — los usuarios gratuitos ven qué gráficos están disponibles antes de desbloquear -• Paywall mejorado — un gráfico real de portfolio muestra cómo se ve el acceso completo -• Configuración de fuentes más clara — descripción breve al crear tu primera fuente de inversión +• Sincronización iCloud — tus datos se sincronizan de forma fiable en todos tus dispositivos Apple +• Búsqueda de fuentes — encuentra cualquier fuente por nombre rápidamente +• Filtros por categoría — filtra tus fuentes por tipo de activo +• Correcciones de errores y mejoras de rendimiento diff --git a/fastlane/metadata/fr-FR/release_notes.txt b/fastlane/metadata/fr-FR/release_notes.txt index 2d7e8ae..4190b83 100644 --- a/fastlane/metadata/fr-FR/release_notes.txt +++ b/fastlane/metadata/fr-FR/release_notes.txt @@ -1,7 +1,6 @@ -Intégration améliorée et rappels mensuels : +Améliorations de la synchronisation iCloud et de la gestion des sources : -• Première expérience repensée — une action claire pour ajouter votre premier investissement, sans étapes superflues -• Rappels mensuels — notifications facultatives pour ne jamais oublier la mise à jour mensuelle de votre portefeuille -• Aperçu des graphiques — les utilisateurs gratuits voient quels graphiques sont disponibles avant de passer à la version complète -• Paywall amélioré — un vrai graphique de portefeuille montre à quoi ressemble l'accès complet -• Configuration des sources clarifiée — courte description lors de la création de votre première source d'investissement +• Synchronisation iCloud — vos données se synchronisent désormais de manière fiable sur tous vos appareils Apple +• Recherche de sources — trouvez rapidement n'importe quelle source par nom +• Filtres par catégorie — filtrez vos sources par classe d'actifs +• Corrections de bugs et améliorations des performances diff --git a/fastlane/metadata/it/release_notes.txt b/fastlane/metadata/it/release_notes.txt index 82e60e8..d15ec06 100644 --- a/fastlane/metadata/it/release_notes.txt +++ b/fastlane/metadata/it/release_notes.txt @@ -1,7 +1,6 @@ -Onboarding migliorato e promemoria mensili: +Miglioramenti alla sincronizzazione iCloud e alla gestione delle fonti: -• Prima esperienza riprogettata — un'azione chiara per aggiungere il tuo primo investimento, senza passaggi inutili -• Promemoria mensili — notifiche opzionali per non dimenticare l'aggiornamento mensile del portafoglio -• Anteprima grafici — gli utenti gratuiti vedono quali grafici sono disponibili prima di passare alla versione completa -• Paywall migliorato — un vero grafico del portafoglio mostra come appare l'accesso completo -• Configurazione delle fonti più chiara — breve descrizione durante la creazione della prima fonte di investimento +• Sincronizzazione iCloud — i tuoi dati si sincronizzano in modo affidabile su tutti i dispositivi Apple +• Ricerca fonti — trova rapidamente qualsiasi fonte per nome +• Filtri per categoria — filtra le fonti per classe di asset +• Correzioni di bug e miglioramenti delle prestazioni diff --git a/fastlane/metadata/ja/release_notes.txt b/fastlane/metadata/ja/release_notes.txt index a42ee9b..d56b3c0 100644 --- a/fastlane/metadata/ja/release_notes.txt +++ b/fastlane/metadata/ja/release_notes.txt @@ -1,7 +1,6 @@ -改善されたオンボーディングと月次リマインダー: +iCloud同期とソース管理の改善: -• 初回セットアップを刷新 — 最初の投資を追加するためのシンプルな1ステップ、余計な手順なし -• 月次リマインダー — 毎月のポートフォリオ更新を忘れないためのオプション通知 -• グラフのプレビュー — 無料ユーザーがアップグレード前に利用可能なグラフを確認できるように -• パイウォールを改善 — 実際のポートフォリオグラフでフルアクセスの内容をプレビュー -• 投資元の作成をわかりやすく — 最初の投資元を追加する際に簡単な説明文を表示 +• iCloud同期 — すべてのAppleデバイス間でデータが確実に同期されるようになりました +• ソース検索 — 名前で素早くソースを検索 +• カテゴリフィルター — 資産クラス別にソースをフィルタリング +• バグ修正とパフォーマンス改善 diff --git a/fastlane/metadata/pt-BR/release_notes.txt b/fastlane/metadata/pt-BR/release_notes.txt index 95b1e2a..11b6711 100644 --- a/fastlane/metadata/pt-BR/release_notes.txt +++ b/fastlane/metadata/pt-BR/release_notes.txt @@ -1,7 +1,6 @@ -Integração mais clara e lembretes mensais: +Melhorias na sincronização iCloud e no gerenciamento de fontes: -• Primeira experiência redesenhada — uma ação clara para adicionar seu primeiro investimento, sem etapas desnecessárias -• Lembretes mensais — notificações opcionais para nunca esquecer de atualizar sua carteira todo mês -• Prévia de gráficos — usuários gratuitos veem quais gráficos estão disponíveis antes de fazer upgrade -• Paywall melhorado — um gráfico real da carteira mostra como é o acesso completo -• Configuração de fontes mais clara — breve descrição ao criar sua primeira fonte de investimento +• Sincronização iCloud — seus dados agora sincronizam de forma confiável em todos os dispositivos Apple +• Pesquisa de fontes — encontre qualquer fonte rapidamente por nome +• Filtros por categoria — filtre suas fontes por classe de ativo +• Correções de bugs e melhorias de desempenho