Release 1.3.1 (build 20): iCloud sync fix + sources search/filter
iCloud sync: - Fix: deploy CloudKit schema to production so exports work - Fix: PredictionCache marked syncable=NO (binary attr caused partial failures) - Fix: forceExportToiCloud uses createdAt+1ms for guaranteed persistent history - Fix: auto-deduplication on CloudKit import (processRemoteChanges) - Add: NSPersistentHistoryTrackingKey always enabled regardless of CloudKit state - Add: cloudKitForceReload notification so repositories re-fetch on remote changes Sources UI: - Add: horizontal category filter chips in SourceListView - Add: search toggle button with animated TextField Other: - Add: ITSAppUsesNonExemptEncryption=NO in Info.plist (skip manual compliance) - Add: fastlane submit lane with run_precheck_before_submit=false - Update: release notes all locales for 1.3.1 - Update: fastlane API key via pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -459,7 +459,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -498,7 +498,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -516,7 +516,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -655,7 +655,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -668,7 +668,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -688,7 +688,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -701,7 +701,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -719,7 +719,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -728,7 +728,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@@ -743,7 +743,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -752,7 +752,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -775,7 +775,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@@ -789,7 +789,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -798,7 +798,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@
|
|||||||
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
|
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
|
||||||
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||||
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import CloudKit
|
|||||||
import Combine
|
import Combine
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let cloudKitForceReload = Notification.Name("cloudKitForceReload")
|
||||||
|
}
|
||||||
|
|
||||||
class CoreDataStack: ObservableObject {
|
class CoreDataStack: ObservableObject {
|
||||||
static let shared = CoreDataStack()
|
static let shared = CoreDataStack()
|
||||||
|
|
||||||
@@ -115,6 +119,35 @@ class CoreDataStack: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Published private(set) var isLoaded = false
|
@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<NSManagedObject>(entityName: "InvestmentSource")
|
||||||
|
return (try? viewContext.count(for: request)) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var localSnapshotCount: Int {
|
||||||
|
let request = NSFetchRequest<NSManagedObject>(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 = {
|
lazy var persistentContainer: NSPersistentContainer = {
|
||||||
let container: NSPersistentContainer
|
let container: NSPersistentContainer
|
||||||
@@ -131,12 +164,15 @@ class CoreDataStack: ObservableObject {
|
|||||||
description.shouldMigrateStoreAutomatically = true
|
description.shouldMigrateStoreAutomatically = true
|
||||||
description.shouldInferMappingModelAutomatically = 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 {
|
if Self.cloudKitEnabled {
|
||||||
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
||||||
containerIdentifier: Self.cloudKitContainerIdentifier
|
containerIdentifier: Self.cloudKitContainerIdentifier
|
||||||
)
|
)
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.persistentStoreDescriptions = [description]
|
container.persistentStoreDescriptions = [description]
|
||||||
@@ -177,42 +213,44 @@ class CoreDataStack: ObservableObject {
|
|||||||
return persistentContainer.viewContext
|
return persistentContainer.viewContext
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Cleanup Duplicates
|
// MARK: - Cleanup Duplicates
|
||||||
|
|
||||||
/// Removes duplicate objects that have the same UUID, keeping only the oldest one.
|
/// Removes duplicate objects that have the same UUID, keeping only the oldest one.
|
||||||
/// This fixes data corruption from race conditions during object creation.
|
/// This fixes data corruption from race conditions during object creation, and also
|
||||||
func cleanupDuplicateObjects() {
|
/// 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
|
let context = viewContext
|
||||||
context.performAndWait {
|
context.performAndWait {
|
||||||
// Clean up duplicate Goals
|
// Clean up Snapshots first — before cascade rules from InvestmentSource fire,
|
||||||
cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
|
// so we deduplicate by UUID and not rely solely on cascade.
|
||||||
// Clean up duplicate Accounts (already handled in AccountRepository but added here for safety)
|
totalRemoved += cleanupDuplicates(entityName: "Snapshot", idKey: "id", context: context)
|
||||||
cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
|
totalRemoved += cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
|
||||||
// Clean up duplicate InvestmentSources
|
totalRemoved += cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
|
||||||
cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
|
totalRemoved += cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
|
||||||
// Clean up duplicate Categories
|
totalRemoved += cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
|
||||||
cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
|
|
||||||
|
|
||||||
if context.hasChanges {
|
if context.hasChanges {
|
||||||
try? context.save()
|
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<NSManagedObject>(entityName: entityName)
|
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||||
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
|
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<UUID>()
|
var seenIds = Set<UUID>()
|
||||||
var objectsToDelete: [NSManagedObject] = []
|
var objectsToDelete: [NSManagedObject] = []
|
||||||
|
|
||||||
for object in objects {
|
for object in objects {
|
||||||
guard let objectId = object.value(forKey: idKey) as? UUID else { continue }
|
guard let objectId = object.value(forKey: idKey) as? UUID else { continue }
|
||||||
|
|
||||||
if seenIds.contains(objectId) {
|
if seenIds.contains(objectId) {
|
||||||
objectsToDelete.append(object)
|
objectsToDelete.append(object)
|
||||||
} else {
|
} else {
|
||||||
@@ -221,11 +259,10 @@ class CoreDataStack: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !objectsToDelete.isEmpty {
|
if !objectsToDelete.isEmpty {
|
||||||
print("Cleaning up \(objectsToDelete.count) duplicate \(entityName) objects")
|
print("[Dedup] Removing \(objectsToDelete.count) duplicate \(entityName) objects")
|
||||||
for object in objectsToDelete {
|
for object in objectsToDelete { context.delete(object) }
|
||||||
context.delete(object)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return objectsToDelete.count
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Save Context
|
// MARK: - Save Context
|
||||||
@@ -260,17 +297,118 @@ class CoreDataStack: ObservableObject {
|
|||||||
@objc private func processRemoteChanges(_ notification: Notification) {
|
@objc private func processRemoteChanges(_ notification: Notification) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// Force viewContext to re-read all objects from the persistent store,
|
// 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.
|
|
||||||
self.viewContext.refreshAllObjects()
|
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.objectWillChange.send()
|
||||||
self.save()
|
self.save()
|
||||||
self.refreshWidgetData()
|
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<NSManagedObject>(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.
|
/// 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
|
/// 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.
|
/// other devices (while this device was inactive) are picked up right away.
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ class AccountRepository: ObservableObject {
|
|||||||
self?.fetchAccounts()
|
self?.fetchAccounts()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.fetchAccounts() }
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class CategoryRepository: ObservableObject {
|
|||||||
self.fetchCategories()
|
self.fetchCategories()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.fetchCategories() }
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ class GoalRepository: ObservableObject {
|
|||||||
self?.fetchGoals()
|
self?.fetchGoals()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.fetchGoals() }
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class InvestmentSourceRepository: ObservableObject {
|
|||||||
self.fetchSources()
|
self.fetchSources()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.fetchSources() }
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
<string>ca-app-pub-1549720748100858~9632507420</string>
|
<string>ca-app-pub-1549720748100858~9632507420</string>
|
||||||
<key>GADDelayAppMeasurementInit</key>
|
<key>GADDelayAppMeasurementInit</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|||||||
@@ -293,6 +293,25 @@
|
|||||||
"onboarding_add_first_source" = "Add My First Investment";
|
"onboarding_add_first_source" = "Add My First Investment";
|
||||||
"onboarding_import_data" = "Import Existing Data";
|
"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)
|
// MARK: - AddSourceView (1.3.0)
|
||||||
"add_source_name_placeholder" = "e.g. MSCI World ETF, ING Savings, Apartment...";
|
"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.";
|
"add_source_name_footer" = "A source is any investment you want to track: stocks, ETFs, savings accounts, real estate, crypto, and more.";
|
||||||
|
|||||||
@@ -222,6 +222,23 @@
|
|||||||
"onboarding_add_first_source" = "Añadir mi primera inversión";
|
"onboarding_add_first_source" = "Añadir mi primera inversión";
|
||||||
"onboarding_import_data" = "Importar datos existentes";
|
"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)
|
// MARK: - AddSourceView (1.3.0)
|
||||||
"add_source_name_placeholder" = "p.ej. ETF MSCI World, Cuenta ING, Piso...";
|
"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.";
|
"add_source_name_footer" = "Una fuente es cualquier inversión que quieras seguir: acciones, ETFs, cuentas de ahorro, inmuebles, cripto y más.";
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
|
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
|
||||||
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
||||||
|
|
||||||
|
@ObservedObject private var cloudStack = CoreDataStack.shared
|
||||||
|
|
||||||
@State private var showingPinSetup = false
|
@State private var showingPinSetup = false
|
||||||
@State private var showingPinChange = false
|
@State private var showingPinChange = false
|
||||||
@State private var showingBiometricAlert = false
|
@State private var showingBiometricAlert = false
|
||||||
@@ -22,6 +24,8 @@ struct SettingsView: View {
|
|||||||
@State private var showingPinVerifyForFaceId = false
|
@State private var showingPinVerifyForFaceId = false
|
||||||
@State private var showingRestartAlert = false
|
@State private var showingRestartAlert = false
|
||||||
@State private var didLoadCloudSync = false
|
@State private var didLoadCloudSync = false
|
||||||
|
@State private var isForceUploading = false
|
||||||
|
@State private var forceUploadResult: String?
|
||||||
@State private var backupToRestore: BackupRecord?
|
@State private var backupToRestore: BackupRecord?
|
||||||
|
|
||||||
init(iapService: IAPService) {
|
init(iapService: IAPService) {
|
||||||
@@ -342,6 +346,104 @@ struct SettingsView: View {
|
|||||||
viewModel.refreshBackups()
|
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(
|
Toggle(
|
||||||
isOn: Binding(
|
isOn: Binding(
|
||||||
get: { viewModel.backupsEnabled },
|
get: { viewModel.backupsEnabled },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct SourceListView: View {
|
|||||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||||
@State private var sourceToDelete: InvestmentSource?
|
@State private var sourceToDelete: InvestmentSource?
|
||||||
@State private var navigationPath = NavigationPath()
|
@State private var navigationPath = NavigationPath()
|
||||||
|
@State private var showingSearch = false
|
||||||
|
|
||||||
init(iapService: IAPService) {
|
init(iapService: IAPService) {
|
||||||
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
|
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
|
||||||
@@ -24,11 +25,13 @@ struct SourceListView: View {
|
|||||||
emptyStateView
|
emptyStateView
|
||||||
} else {
|
} else {
|
||||||
sourcesList
|
sourcesList
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
filterBar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Sources")
|
.navigationTitle("Sources")
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Search sources")
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
@@ -38,8 +41,16 @@ struct SourceListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
categoryFilterMenu
|
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) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
@@ -213,46 +224,64 @@ struct SourceListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Category Filter Menu
|
// MARK: - Filter Bar (chips + search)
|
||||||
|
|
||||||
private var categoryFilterMenu: some View {
|
private var filterBar: some View {
|
||||||
Menu {
|
VStack(spacing: 0) {
|
||||||
Button {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
viewModel.selectCategory(nil)
|
HStack(spacing: 8) {
|
||||||
} label: {
|
CategoryChip(
|
||||||
HStack {
|
title: String(localized: "sources_filter_all"),
|
||||||
Text("All Categories")
|
icon: nil,
|
||||||
if viewModel.selectedCategory == nil {
|
isSelected: viewModel.selectedCategory == nil
|
||||||
Image(systemName: "checkmark")
|
) {
|
||||||
|
viewModel.selectCategory(nil)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
ForEach(viewModel.categories) { category in
|
||||||
|
CategoryChip(
|
||||||
ForEach(viewModel.categories) { category in
|
title: category.name,
|
||||||
Button {
|
icon: category.icon,
|
||||||
viewModel.selectCategory(category)
|
isSelected: viewModel.selectedCategory?.id == category.id
|
||||||
} label: {
|
) {
|
||||||
HStack {
|
viewModel.selectCategory(category)
|
||||||
Image(systemName: category.icon)
|
|
||||||
Text(category.name)
|
|
||||||
if viewModel.selectedCategory?.id == category.id {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
if showingSearch {
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
HStack(spacing: 8) {
|
||||||
if viewModel.selectedCategory != nil {
|
Image(systemName: "magnifyingglass")
|
||||||
Circle()
|
.foregroundColor(.secondary)
|
||||||
.fill(Color.appPrimary)
|
|
||||||
.frame(width: 8, height: 8)
|
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
|
// 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 {
|
private struct MissingSourceView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
|
|||||||
@@ -2,26 +2,28 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Investment Widget</string>
|
<string>Investment Widget</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>NSExtension</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<dict>
|
<false/>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtension</key>
|
||||||
<string>com.apple.widgetkit-extension</string>
|
<dict>
|
||||||
</dict>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -117,7 +117,11 @@ export_ipa() {
|
|||||||
-exportArchive \
|
-exportArchive \
|
||||||
-archivePath "$ARCHIVE_PATH" \
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
-exportPath "$EXPORT_PATH" \
|
-exportPath "$EXPORT_PATH" \
|
||||||
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST"
|
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
-authenticationKeyPath "$P8_PATH" \
|
||||||
|
-authenticationKeyID "$KEY_ID" \
|
||||||
|
-authenticationKeyIssuerID "$ISSUER_ID"
|
||||||
}
|
}
|
||||||
|
|
||||||
upload_ipa() {
|
upload_ipa() {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
app_version "1.3.0"
|
app_version "1.3.1"
|
||||||
|
|||||||
+29
-4
@@ -1,8 +1,5 @@
|
|||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
# Credentials via `pass`
|
|
||||||
# export FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=$(pass show appstore/apple-app-specific-password)
|
|
||||||
|
|
||||||
platform :ios do
|
platform :ios do
|
||||||
EXPORT_OPTIONS = {
|
EXPORT_OPTIONS = {
|
||||||
method: "app-store",
|
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)"
|
desc "Upload metadata and release notes only (binary already uploaded)"
|
||||||
lane :metadata do
|
lane :metadata do
|
||||||
deliver(
|
deliver(
|
||||||
|
api_key: api_key,
|
||||||
metadata_path: "fastlane/metadata",
|
metadata_path: "fastlane/metadata",
|
||||||
skip_screenshots: true,
|
skip_screenshots: true,
|
||||||
skip_binary_upload: true,
|
skip_binary_upload: true,
|
||||||
@@ -30,7 +37,24 @@ platform :ios do
|
|||||||
scheme: "PortfolioJournal",
|
scheme: "PortfolioJournal",
|
||||||
export_options: EXPORT_OPTIONS
|
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
|
end
|
||||||
|
|
||||||
desc "Build, export and push a new release to the App Store"
|
desc "Build, export and push a new release to the App Store"
|
||||||
@@ -40,6 +64,7 @@ platform :ios do
|
|||||||
export_options: EXPORT_OPTIONS
|
export_options: EXPORT_OPTIONS
|
||||||
)
|
)
|
||||||
upload_to_app_store(
|
upload_to_app_store(
|
||||||
|
api_key: api_key,
|
||||||
metadata_path: "fastlane/metadata",
|
metadata_path: "fastlane/metadata",
|
||||||
skip_screenshots: true,
|
skip_screenshots: true,
|
||||||
force: true
|
force: true
|
||||||
|
|||||||
@@ -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
|
• iCloud-Sync — deine Daten synchronisieren sich jetzt zuverlässig auf allen Apple-Geräten
|
||||||
• Monatliche Erinnerungen — optionale Benachrichtigungen, damit du das monatliche Portfolio-Update nicht vergisst
|
• Quellensuche — finde jede Quelle schnell nach Namen
|
||||||
• Charts-Vorschau — kostenlose Nutzer sehen, welche Charts verfügbar sind, bevor sie upgraden
|
• Kategoriefilter — filtere deine Quellen nach Anlageklasse
|
||||||
• Verbesserter Paywall — eine echte Portfolio-Grafik zeigt, wie der Vollzugang aussieht
|
• Fehlerbehebungen und Leistungsverbesserungen
|
||||||
• Klarere Quelleneinrichtung — kurze Erklärung beim Erstellen deiner ersten Anlageposition
|
|
||||||
|
|||||||
@@ -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
|
• iCloud sync — your data now syncs reliably across all your Apple devices
|
||||||
• Monthly reminders — optional notifications so you never forget your monthly portfolio update
|
• Source search — quickly find any source by name
|
||||||
• Charts preview — free users now see which charts are available before upgrading
|
• Category filters — filter your sources by asset category
|
||||||
• Improved paywall — a live portfolio chart preview shows what full access looks like
|
• Bug fixes and performance improvements
|
||||||
• Clearer source setup — added a short description when creating your first investment source
|
|
||||||
|
|||||||
@@ -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
|
• Sincronización iCloud — tus datos se sincronizan de forma fiable en todos tus dispositivos Apple
|
||||||
• Recordatorios mensuales — notificaciones opcionales para no olvidar actualizar tu portfolio cada mes
|
• Búsqueda de fuentes — encuentra cualquier fuente por nombre rápidamente
|
||||||
• Vista previa de gráficos — los usuarios gratuitos ven qué gráficos están disponibles antes de desbloquear
|
• Filtros por categoría — filtra tus fuentes por tipo de activo
|
||||||
• Paywall mejorado — un gráfico real de portfolio muestra cómo se ve el acceso completo
|
• Correcciones de errores y mejoras de rendimiento
|
||||||
• Configuración de fuentes más clara — descripción breve al crear tu primera fuente de inversión
|
|
||||||
|
|||||||
@@ -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
|
• Synchronisation iCloud — vos données se synchronisent désormais de manière fiable sur tous vos appareils Apple
|
||||||
• Rappels mensuels — notifications facultatives pour ne jamais oublier la mise à jour mensuelle de votre portefeuille
|
• Recherche de sources — trouvez rapidement n'importe quelle source par nom
|
||||||
• Aperçu des graphiques — les utilisateurs gratuits voient quels graphiques sont disponibles avant de passer à la version complète
|
• Filtres par catégorie — filtrez vos sources par classe d'actifs
|
||||||
• Paywall amélioré — un vrai graphique de portefeuille montre à quoi ressemble l'accès complet
|
• Corrections de bugs et améliorations des performances
|
||||||
• Configuration des sources clarifiée — courte description lors de la création de votre première source d'investissement
|
|
||||||
|
|||||||
@@ -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
|
• Sincronizzazione iCloud — i tuoi dati si sincronizzano in modo affidabile su tutti i dispositivi Apple
|
||||||
• Promemoria mensili — notifiche opzionali per non dimenticare l'aggiornamento mensile del portafoglio
|
• Ricerca fonti — trova rapidamente qualsiasi fonte per nome
|
||||||
• Anteprima grafici — gli utenti gratuiti vedono quali grafici sono disponibili prima di passare alla versione completa
|
• Filtri per categoria — filtra le fonti per classe di asset
|
||||||
• Paywall migliorato — un vero grafico del portafoglio mostra come appare l'accesso completo
|
• Correzioni di bug e miglioramenti delle prestazioni
|
||||||
• Configurazione delle fonti più chiara — breve descrizione durante la creazione della prima fonte di investimento
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
改善されたオンボーディングと月次リマインダー:
|
iCloud同期とソース管理の改善:
|
||||||
|
|
||||||
• 初回セットアップを刷新 — 最初の投資を追加するためのシンプルな1ステップ、余計な手順なし
|
• iCloud同期 — すべてのAppleデバイス間でデータが確実に同期されるようになりました
|
||||||
• 月次リマインダー — 毎月のポートフォリオ更新を忘れないためのオプション通知
|
• ソース検索 — 名前で素早くソースを検索
|
||||||
• グラフのプレビュー — 無料ユーザーがアップグレード前に利用可能なグラフを確認できるように
|
• カテゴリフィルター — 資産クラス別にソースをフィルタリング
|
||||||
• パイウォールを改善 — 実際のポートフォリオグラフでフルアクセスの内容をプレビュー
|
• バグ修正とパフォーマンス改善
|
||||||
• 投資元の作成をわかりやすく — 最初の投資元を追加する際に簡単な説明文を表示
|
|
||||||
|
|||||||
@@ -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
|
• Sincronização iCloud — seus dados agora sincronizam de forma confiável em todos os dispositivos Apple
|
||||||
• Lembretes mensais — notificações opcionais para nunca esquecer de atualizar sua carteira todo mês
|
• Pesquisa de fontes — encontre qualquer fonte rapidamente por nome
|
||||||
• Prévia de gráficos — usuários gratuitos veem quais gráficos estão disponíveis antes de fazer upgrade
|
• Filtros por categoria — filtre suas fontes por classe de ativo
|
||||||
• Paywall melhorado — um gráfico real da carteira mostra como é o acesso completo
|
• Correções de bugs e melhorias de desempenho
|
||||||
• Configuração de fontes mais clara — breve descrição ao criar sua primeira fonte de investimento
|
|
||||||
|
|||||||
Reference in New Issue
Block a user