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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user