Release 1.2.1: iCloud sync improvements + ASO multilingual metadata

iCloud sync:
- Force viewContext.refreshAllObjects() on remote change notifications so
  data from other devices is picked up immediately without app restart
- Call refreshFromCloudKit() on foreground to merge any changes made while
  the device was inactive
- Wait up to 10s for initial CloudKit sync on launch before showing onboarding
  (shows "Checking iCloud..." during the wait)
- New OnboardingICloudCheckView: shown on fresh installs with iCloud available,
  lets user restore from iCloud before starting onboarding from scratch

Localization:
- Added de, fr, it, ja, pt-BR lproj folders
- New iCloud onboarding strings in en + es-ES (+ button literals)

ASO metadata (fastlane):
- Updated en-US: new subtitle, keywords, description (fixed "no cloud sync"
  claim), promotional text, release notes
- Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total)
- All keyword fields validated ≤100 Unicode chars

Infrastructure:
- Gemfile + Gemfile.lock for fastlane
- Scripts/archive_and_upload_appstore.sh for CI/CD

Version: 1.2.1 (build 7)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-03-25 11:54:35 +01:00
parent 7cb5f92cf4
commit 10f6d0ca20
108 changed files with 4069 additions and 238 deletions
+33 -1
View File
@@ -14,16 +14,29 @@ struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var isUnlocked = false
@State private var resolvedOnboardingCompleted: Bool?
@State private var iCloudCheckDone = false
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
private var lockEnabled: Bool {
faceIdEnabled || pinEnabled
}
/// True when a fresh install with iCloud available and sync not yet enabled.
/// Only relevant before onboarding is completed.
private var needsICloudCheck: Bool {
guard resolvedOnboardingCompleted == false else { return false }
guard !UserDefaults.standard.bool(forKey: "cloudSyncEnabled") else { return false }
return FileManager.default.ubiquityIdentityToken != nil
}
var body: some View {
ZStack {
Group {
if !isReadyForContent {
AppLaunchLoadingView(messageKey: "loading_data")
AppLaunchLoadingView(messageKey: loadingMessageKey)
} else if needsICloudCheck && !iCloudCheckDone {
// Fresh install with iCloud available: ask before showing onboarding
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
} else if resolvedOnboardingCompleted == false {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else {
@@ -78,12 +91,31 @@ struct ContentView: View {
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
}
// If CloudKit is enabled and no local data yet, wait briefly for the
// initial iCloud sync so data from other devices appears before we
// decide whether to show onboarding.
if UserDefaults.standard.bool(forKey: "cloudSyncEnabled") && !hasExistingData() {
await MainActor.run { loadingMessageKey = "checking_icloud" }
await waitForInitialCloudKitSync(timeout: 10)
await MainActor.run { loadingMessageKey = "loading_data" }
}
// Resolve onboarding state on main thread
await MainActor.run {
syncOnboardingState()
}
}
/// Polls for existing data up to `timeout` seconds, returning as soon as
/// any data appears. Used to wait for the initial CloudKit sync on launch.
private func waitForInitialCloudKitSync(timeout: TimeInterval) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if hasExistingData() { return }
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
}
}
private func syncOnboardingState() {
let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext)
var resolved = settings.onboardingCompleted || onboardingCompleted
@@ -34,6 +34,10 @@ struct PortfolioJournalApp: App {
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
coreDataStack.refreshWidgetData()
// Re-read all Core Data objects from the persistent store so that
// iCloud changes made on other devices while this device was inactive
// are reflected immediately without waiting for a remote-change notification.
coreDataStack.refreshFromCloudKit()
} else if newPhase == .background {
guard iapService.isPremium else { return }
guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return }