Files
edgeever/apps/ios/EdgeEver/App/AppEnvironment.swift
T
tianma-if a32272019f feat(ios): show first-login note sync progress like Android
Surface bootstrap loaded/total counts with the same loading card, list banner, and error UI so first sign-in feels responsive instead of empty.
2026-08-07 23:07:44 +08:00

116 lines
4.2 KiB
Swift

import Foundation
import Observation
@Observable
@MainActor
final class AppEnvironment {
let session: SessionStore
let mirror: LocalMirrorRepository
let outbox: SyncOutboxRepository
let drafts: DraftRepository
let syncEngine: SyncEngine
let outboxFlusher: OutboxFlusher
let preferences: PreferencesStore
let shareHandoff: ShareHandoffStore
private(set) var bootstrapProgress: BootstrapProgress?
private(set) var isSyncing = false
private(set) var lastSyncError: String?
private(set) var lastOutboxResult: SyncRunResult?
init() {
let dbQueue = try! AppDatabase.makeShared()
let mirror = LocalMirrorRepository(dbQueue: dbQueue)
let outbox = SyncOutboxRepository(dbQueue: dbQueue)
let drafts = DraftRepository(dbQueue: dbQueue)
let session = SessionStore()
let syncEngine = SyncEngine(mirror: mirror, client: session.client)
let flusher = OutboxFlusher(outbox: outbox, mirror: mirror, client: session.client)
self.session = session
self.mirror = mirror
self.outbox = outbox
self.drafts = drafts
self.syncEngine = syncEngine
self.outboxFlusher = flusher
self.preferences = PreferencesStore()
self.shareHandoff = ShareHandoffStore()
}
/// Test-friendly initializer.
init(
session: SessionStore,
mirror: LocalMirrorRepository,
outbox: SyncOutboxRepository,
drafts: DraftRepository,
syncEngine: SyncEngine,
outboxFlusher: OutboxFlusher,
preferences: PreferencesStore = PreferencesStore(),
shareHandoff: ShareHandoffStore = ShareHandoffStore()
) {
self.session = session
self.mirror = mirror
self.outbox = outbox
self.drafts = drafts
self.syncEngine = syncEngine
self.outboxFlusher = outboxFlusher
self.preferences = preferences
self.shareHandoff = shareHandoff
}
func bootstrap() async {
await session.bootstrap()
if session.isSignedIn {
// Surface first-sync progress before TipTap warm / network so the workspace
// never flashes an empty-notebook state on cold start.
if let scope = session.dataScope,
!((try? mirror.isInitialized(scope: scope)) ?? false)
{
bootstrapProgress = BootstrapProgress(loadedCount: 0, totalCount: 0)
}
// Warm TipTap process + EditorBundle off the critical path of the first note open.
await MainActor.run { TipTapWarmPool.warmIfNeeded() }
await runSyncCycle()
}
}
/// - Parameter force: when true, clear outbox backoff and retry error/conflict items immediately
/// (Android pull-to-refresh / "Sync now" parity).
func runSyncCycle(force: Bool = false) async {
guard let scope = session.dataScope else { return }
isSyncing = true
lastSyncError = nil
defer { isSyncing = false }
// Android parity: only surface bootstrap progress UI on the first mirror fill
// (cursor/identity missing). Incremental sync stays quiet.
let isInitialSync = !((try? mirror.isInitialized(scope: scope)) ?? false)
if isInitialSync {
bootstrapProgress = BootstrapProgress(loadedCount: 0, totalCount: 0)
}
do {
_ = try await outboxFlusher.flush(scope: scope, force: force)
let progressHandler: (@Sendable (BootstrapProgress) -> Void)?
if isInitialSync {
progressHandler = { [weak self] progress in
DispatchQueue.main.async {
self?.bootstrapProgress = progress
}
}
} else {
progressHandler = nil
}
_ = try await syncEngine.sync(scope: scope, onBootstrapProgress: progressHandler)
lastOutboxResult = try await outboxFlusher.flush(scope: scope, force: force)
bootstrapProgress = nil
} catch {
lastSyncError = error.localizedDescription
if isInitialSync {
bootstrapProgress = nil
}
}
}
}