diff --git a/Sources/App/ZitareNativeApp.swift b/Sources/App/ZitareNativeApp.swift index 8e22604..7b583c5 100644 --- a/Sources/App/ZitareNativeApp.swift +++ b/Sources/App/ZitareNativeApp.swift @@ -10,6 +10,7 @@ struct ZitareNativeApp: App { @State private var authGate: ManaAuthGate @State private var submissionQueue: SubmissionQueue private let snapshotContainer: ModelContainer? + private let reachability = ReachabilityWatcher() init() { let auth = AuthClient(config: AppConfig.manaAppConfig) @@ -51,6 +52,7 @@ struct ZitareNativeApp: App { .task { await refreshSnapshot() await flushPending() + startReachability() } } } @@ -63,6 +65,24 @@ struct ZitareNativeApp: App { } } + /// Startet den NWPathMonitor und hooked Reconnect → Flush. + /// `Task.detached` + `await MainActor`, weil das Reconnect-Closure + /// vom Network-Framework auf einer privaten Queue feuert. + private func startReachability() { + let queue = submissionQueue + let auth = auth + reachability.start { [weak queue] in + guard let queue else { return } + Task { @MainActor in + let api = ZitareAPI(auth: auth) + let sent = await queue.tryFlush(api: api) + if sent > 0 { + Log.app.info("Reachability-Flush: \(sent) Submission(s) nachgereicht") + } + } + } + } + private func refreshSnapshot() async { guard let container = snapshotContainer else { return } let sync = SnapshotSync(container: container) diff --git a/Sources/Core/Submit/ReachabilityWatcher.swift b/Sources/Core/Submit/ReachabilityWatcher.swift new file mode 100644 index 0000000..7a9f39e --- /dev/null +++ b/Sources/Core/Submit/ReachabilityWatcher.swift @@ -0,0 +1,40 @@ +import Foundation +import Network +import OSLog + +/// Wrapper um `NWPathMonitor`. Feuert das übergebene Closure bei jedem +/// Wechsel von "kein Pfad" → "Pfad da" (= Wifi/Mobil wieder verfügbar +/// nach Offline). Ein wechselnder Pfad (Wifi → Mobil) feuert nicht +/// erneut — wir wollen nur die Reconnect-Flanke. +/// +/// Lebenszyklus liegt beim App-Root. Stop-Methode existiert, aber +/// reale App startet Watcher einmal beim Launch und lässt ihn bis +/// zum Prozess-Ende laufen. +final class ReachabilityWatcher: @unchecked Sendable { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "ev.mana.zitare.reachability") + private let log = Logger(subsystem: "ev.mana.zitare", category: "reachability") + private var wasReachable: Bool? = nil + private var onReconnect: @Sendable () -> Void = {} + + func start(onReconnect: @escaping @Sendable () -> Void) { + self.onReconnect = onReconnect + monitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + let reachable = path.status == .satisfied + let prev = self.wasReachable + self.wasReachable = reachable + if prev == false, reachable { + self.log.info("Reachable again — triggering reconnect-hook") + self.onReconnect() + } else if prev == nil { + self.log.info("Initial reachability: \(reachable ? "yes" : "no", privacy: .public)") + } + } + monitor.start(queue: queue) + } + + func stop() { + monitor.cancel() + } +} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index 22483b7..98e2fa2 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -9,8 +9,12 @@ import SwiftUI struct AccountView: View { @Environment(AuthClient.self) private var auth @Environment(ManaAuthGate.self) private var authGate + @Environment(SubmissionQueue.self) private var submissionQueue let healthStatus: HealthStatus + @State private var pendingEntries: [PendingSubmission] = [] + @State private var refreshTick: Int = 0 + private var isSignedIn: Bool { if case .signedIn = auth.status { return true } return false @@ -25,6 +29,10 @@ struct AccountView: View { authActionCard + if !pendingEntries.isEmpty { + pendingQueueCard + } + Spacer(minLength: 32) aboutCard @@ -33,6 +41,86 @@ struct AccountView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(ZitareTheme.background) + .task { + reload() + } + .refreshable { + reload() + } + } + + private var pendingQueueCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Offline-Warteschlange", systemImage: "tray.and.arrow.up") + .font(.headline) + Spacer() + Button { + Task { + let api = ZitareAPI(auth: auth) + _ = await submissionQueue.tryFlush(api: api) + reload() + } + } label: { + Label("Jetzt versuchen", systemImage: "arrow.clockwise") + .font(.callout) + } + .tint(ZitareTheme.primary) + } + ForEach(pendingEntries) { entry in + pendingRow(entry) + if entry.id != pendingEntries.last?.id { + Divider() + } + } + } + .padding() + .background(ZitareTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(ZitareTheme.border, lineWidth: 1) + ) + } + + private func pendingRow(_ entry: PendingSubmission) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(entry.text.prefix(120) + (entry.text.count > 120 ? "…" : "")) + .font(.callout) + .foregroundStyle(ZitareTheme.foreground) + HStack(spacing: 8) { + if let author = entry.authorName { + Text(author) + .font(.caption.weight(.medium)) + } + Text(entry.createdAt, format: .dateTime.day().month().hour().minute()) + .font(.caption) + if entry.retryCount > 0 { + Text("· \(entry.retryCount) Versuch(e)") + .font(.caption) + } + Spacer() + Button(role: .destructive) { + submissionQueue.delete(id: entry.id) + reload() + } label: { + Image(systemName: "trash") + .foregroundStyle(ZitareTheme.mutedForeground) + } + .buttonStyle(.plain) + } + .foregroundStyle(ZitareTheme.mutedForeground) + if let err = entry.lastError { + Text(err) + .font(.caption) + .foregroundStyle(ZitareTheme.error) + } + } + } + + private func reload() { + pendingEntries = submissionQueue.loadAll() + refreshTick &+= 1 } @ViewBuilder