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:
alexandrev-tibco
2026-05-07 22:19:33 +02:00
parent 8acac3d529
commit 773da6800b
23 changed files with 524 additions and 144 deletions
@@ -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 },
@@ -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) {