163fd6026a
CloudKit sincroniza la base privada de un Apple ID: ni llega a Android ni deja que dos cuentas editen el mismo plan (CKShare sigue sin existir en SwiftData). El contenido de un hogar pasa por tanto a Firestore, y un dispositivo que entra en un hogar construye el store local sin CloudKit — dos espejos escribiendo los mismos objetos se pelean, que es justo lo que ya obligó a apagar el sync por iCloud KV. SwiftData sigue siendo el store local y el modo offline; HouseholdSyncService es lo unico que habla con la red. Detecta cambios comparando una huella del contenido de cada documento con la ultima sincronizada (el "shadow"), asi que no hace falta instrumentar con updatedAt las treinta vistas que mutan modelos. Los borrados van como tombstone: un borrado duro volveria desde cualquier miembro que estuviera sin conexion. Semanas y slots usan id derivado del contenido (2026-09-14, 5-dinner) para que dos miembros que abren la misma semana escriban el mismo documento en vez de crear dos, y para que los conflictos se resuelvan por slot y no por semana. Incluye reglas de seguridad (solo miembros; los codigos de invitacion se pueden leer por id pero no listar), pantalla de hogar en Ajustes con Sign in with Apple, invitacion por codigo de 6 caracteres sin vocales ni 0/O/1/I, y la eleccion al unirse entre llevarse los platos propios o adoptar los del hogar. Fuera de esta fase: fotos de platos (necesitan Storage) y el cliente Android. Refs #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
105 lines
4.0 KiB
Swift
105 lines
4.0 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import UIKit
|
|
import FirebaseCore
|
|
import FirebaseCrashlytics
|
|
#if canImport(GoogleMobileAds)
|
|
import GoogleMobileAds
|
|
#endif
|
|
|
|
/// Registers for remote notifications so CloudKit (the NSPersistentCloudKitContainer
|
|
/// behind SwiftData) receives its silent-push subscriptions and imports changes
|
|
/// in near-real-time across devices. Silent pushes are coalesced by the system,
|
|
/// so battery cost is negligible — no polling.
|
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
|
func application(_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
application.registerForRemoteNotifications()
|
|
return true
|
|
}
|
|
|
|
func application(_ application: UIApplication,
|
|
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
|
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
|
// CloudKit silent push: SwiftData's mirror imports on its own once woken;
|
|
// report new data so the system keeps delivering sync pushes.
|
|
completionHandler(.newData)
|
|
}
|
|
}
|
|
|
|
/// Whether the store is syncing through CloudKit this launch. When true, the
|
|
/// legacy iCloud KV snapshot sync must stay inert (both would fight).
|
|
enum CloudSyncRuntime {
|
|
// Written exactly once during app start (before any concurrency), then
|
|
// read-only for the rest of the launch.
|
|
nonisolated(unsafe) static var isCloudKitActive = false
|
|
}
|
|
|
|
@main
|
|
struct MealMoodApp: App {
|
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
private let modelContainer: ModelContainer = {
|
|
let schema = Schema([
|
|
AppSettings.self,
|
|
Tag.self,
|
|
Dish.self,
|
|
WeekPlan.self,
|
|
MealSlot.self,
|
|
ShoppingItem.self
|
|
])
|
|
|
|
// 2.2: inside a household the data lives in Firestore (shared with the
|
|
// other members, reachable from Android) and the store stays local.
|
|
// Running CloudKit on top would mean two mirrors writing the same
|
|
// objects. See docs/household-sync.md.
|
|
HouseholdRuntime.isHouseholdStore = HouseholdRuntime.householdId != nil
|
|
|
|
// 2.0: CloudKit private-database sync (multi-device, same Apple ID).
|
|
// Falls back to the local-only store when CloudKit isn't available
|
|
// (signed-out iCloud, missing entitlement in dev builds, etc.).
|
|
if !HouseholdRuntime.isHouseholdStore {
|
|
let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic)
|
|
do {
|
|
let container = try ModelContainer(for: schema, configurations: [cloudConfiguration])
|
|
CloudSyncRuntime.isCloudKitActive = true
|
|
return container
|
|
} catch {
|
|
CrashlyticsService.record(error, context: "cloudkit_container")
|
|
print("CloudKit container unavailable, using local store: \(error)")
|
|
}
|
|
}
|
|
|
|
let configuration = ModelConfiguration(cloudKitDatabase: .none)
|
|
do {
|
|
return try ModelContainer(for: schema, configurations: [configuration])
|
|
} catch {
|
|
// Migration failed — reset the store to avoid a crash loop
|
|
let storeURL = configuration.url
|
|
for ext in ["", "-shm", "-wal"] {
|
|
try? FileManager.default.removeItem(at: URL(fileURLWithPath: storeURL.path + ext))
|
|
}
|
|
do {
|
|
return try ModelContainer(for: schema, configurations: [configuration])
|
|
} catch {
|
|
fatalError("Cannot create ModelContainer after reset: \(error)")
|
|
}
|
|
}
|
|
}()
|
|
|
|
init() {
|
|
FirebaseApp.configure()
|
|
AnalyticsService.applyCollectionPolicy()
|
|
#if canImport(GoogleMobileAds)
|
|
GADMobileAds.sharedInstance().start(completionHandler: nil)
|
|
#endif
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
ContentView()
|
|
}
|
|
.modelContainer(modelContainer)
|
|
}
|
|
}
|