From 53f8043a2d0196e5977e5ef1acbb19c45f7339dc Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 19 May 2026 16:26:55 +0200 Subject: [PATCH] =?UTF-8?q?=CE=B6-3.5b:=20Pending-Queue-UI=20+=20NWPathMon?= =?UTF-8?q?itor-Reconnect-Flush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pending-Queue-UI: - AccountView.pendingQueueCard listet alle wartenden Submissions mit Text-Preview (120c), Author, createdAt, retryCount, lastError - "Jetzt versuchen"-Button triggert tryFlush(api:) - Trash-Icon pro Row löscht einzeln aus der Queue - Pull-to-Refresh aktualisiert beide Listen - Card-View nur sichtbar wenn Queue-Tiefe > 0 ReachabilityWatcher: - NWPathMonitor erkennt Reconnect-Flanke (offline → online) - Bei Reconnect: SubmissionQueue.tryFlush auf @MainActor - Filtert reine Wifi↔Mobil-Switches raus (nur "wieder erreichbar" zählt) - Lebt im App-Root, startet nach Launch via .task Files: - Sources/Core/Submit/ReachabilityWatcher.swift (neu) - Sources/App/ZitareNativeApp.swift: reachability.start in .task - Sources/Features/Account/AccountView.swift: pendingQueueCard iOS + macOS BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/ZitareNativeApp.swift | 20 +++++ Sources/Core/Submit/ReachabilityWatcher.swift | 40 +++++++++ Sources/Features/Account/AccountView.swift | 88 +++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 Sources/Core/Submit/ReachabilityWatcher.swift 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