pageta-native/Sources/Core/BadgeManager.swift
Till JS 310a099d07 feat: softFirst refresh policy + Badge für ungelesene Artikel
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>
2026-05-21 15:41:34 +02:00

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)")
}
}
}