Zwei Verbesserungen aus Tills Feedback heute: 1. AppConfig opt-in auf RefreshFailurePolicy.softFirst (mana-swift-core v1.8.0). Verhindert Keychain-Wipe durch einen einzelnen transienten /refresh-Fehler beim Cold-Launch. Hypothese für die wiederholten "Logout nach TestFlight-Update"-Reports: ein 401-Glitch des Servers beim ersten Sync-Call wiped die Session, obwohl sie eigentlich gültig ist. Mit softFirst überlebt sie den ersten Fehler — Wipe erst bei zweitem oder nach einem zwischendurch erfolgreichen Refresh. 2. App-Icon-Badge für ungelesene Artikel — neuer Konto-Tab-Toggle (default off, fragt UNUserNotificationCenter-Permission beim Aktivieren). Zählt nur `.unread` (nicht `.reading`/`.finished`, sonst bleibt der Badge dauerhaft hoch). Update nach jedem LeseListe-Fetch. Reset auf 0 bei Logout. BadgeManager als sauberer Single-Point-Of-Entry für Permission + setBadgeCount. iOS Sim BUILD SUCCEEDED, macOS BUILD SUCCEEDED, 38/38 Tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
69 lines
2.6 KiB
Swift
69 lines
2.6 KiB
Swift
import Foundation
|
|
import UserNotifications
|
|
|
|
/// Steuert das App-Icon-Badge mit der Anzahl ungelesener Artikel.
|
|
///
|
|
/// **Permission:** iOS verlangt für Badge-Updates eine
|
|
/// `UNUserNotificationCenter`-Autorisierung mit der Option `.badge`.
|
|
/// Wir fragen sie an, wenn der User in `AccountTabView` die Badge-
|
|
/// Option aktiviert — nicht beim App-Launch (sonst Prompt-Burst).
|
|
///
|
|
/// **Zähl-Konvention:** "Ungelesen" = `Article.status == .unread`.
|
|
/// `.reading` zählt **nicht**, sonst bleibt der Badge an, sobald der
|
|
/// User irgendetwas angefangen hat zu lesen. `.finished` und
|
|
/// `.archived` zählen ebenfalls nicht.
|
|
///
|
|
/// **Storage:** Die User-Entscheidung lebt in
|
|
/// `UserDefaults.standard["pageta.badge.enabled"]` (Bool). `@AppStorage`
|
|
/// im UI-Layer, hier wird nur gelesen.
|
|
///
|
|
/// **Lifecycle:**
|
|
/// - User aktiviert Badge → `requestPermission()` → bei Grant
|
|
/// wird der Badge gesetzt, beim Logout/Disable auf 0 gesetzt.
|
|
/// - Nach jedem erfolgreichen `LeseListeTabView.loadArticles()`
|
|
/// wird `updateIfEnabled(unreadCount:)` aufgerufen.
|
|
@MainActor
|
|
enum BadgeManager {
|
|
static let userDefaultsKey = "pageta.badge.enabled"
|
|
|
|
/// Liest die User-Toggle-Entscheidung aus UserDefaults. Default `false`.
|
|
static var isEnabled: Bool {
|
|
UserDefaults.standard.bool(forKey: userDefaultsKey)
|
|
}
|
|
|
|
/// Fragt die Badge-Permission an. Wirft nicht — bei Fehler oder
|
|
/// Denial wird `false` zurückgegeben, der Caller setzt dann das
|
|
/// Toggle zurück.
|
|
static func requestPermission() async -> Bool {
|
|
let center = UNUserNotificationCenter.current()
|
|
do {
|
|
return try await center.requestAuthorization(options: [.badge])
|
|
} catch {
|
|
Log.app.error("Badge permission request failed: \(error.localizedDescription, privacy: .public)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Setzt den Badge auf den gegebenen Count, **wenn** der User die
|
|
/// Option aktiviert hat. `count: 0` löscht den Badge.
|
|
///
|
|
/// Bei fehlender Permission ist `setBadgeCount` ein No-Op auf
|
|
/// iOS-Ebene — wir loggen nichts, weil das normal ist (User hat
|
|
/// vielleicht nie geöffnet).
|
|
static func updateIfEnabled(unreadCount: Int) async {
|
|
guard isEnabled else { return }
|
|
await setBadge(count: unreadCount)
|
|
}
|
|
|
|
/// Setzt den Badge unbedingt. Genutzt beim Aktivieren (initialer
|
|
/// Fetch wartet sonst auf den nächsten Liste-Refresh) und beim
|
|
/// Logout/Disable (Reset auf 0).
|
|
static func setBadge(count: Int) async {
|
|
do {
|
|
try await UNUserNotificationCenter.current().setBadgeCount(count)
|
|
Log.app.info("Badge set to \(count, privacy: .public)")
|
|
} catch {
|
|
Log.app.error("Badge update failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|