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