diff --git a/Localizable.xcstrings b/Localizable.xcstrings index bfc66a3..bbc0f89 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -48,6 +48,9 @@ }, "%lld min" : { + }, + "%lld minutes" : { + }, "%lld." : { @@ -99,6 +102,12 @@ }, "Are you sure you want to log out? This will delete all your login credentials and return you to setup." : { + }, + "Automatic sync" : { + + }, + "Automatically mark articles as read" : { + }, "Available tags" : { @@ -111,6 +120,9 @@ }, "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : { + }, + "Clear cache" : { + }, "Close" : { @@ -120,6 +132,9 @@ }, "Critical" : { + }, + "Data Management" : { + }, "Debug" : { @@ -258,6 +273,9 @@ }, "OK" : { + }, + "Open external links in in-app Safari" : { + }, "Optional: Custom title" : { @@ -301,12 +319,18 @@ } } } + }, + "Reading Settings" : { + }, "Remove" : { }, "Reset" : { + }, + "Reset settings" : { + }, "Reset to Defaults" : { @@ -316,6 +340,9 @@ }, "Resume listening" : { + }, + "Safari Reader Mode" : { + }, "Save bookmark" : { @@ -364,6 +391,12 @@ }, "Speed" : { + }, + "Sync interval" : { + + }, + "Sync Settings" : { + }, "Syncing with server..." : { diff --git a/URLShare/SimpleAPI.swift b/URLShare/SimpleAPI.swift index 5a0dd3a..38b658b 100644 --- a/URLShare/SimpleAPI.swift +++ b/URLShare/SimpleAPI.swift @@ -39,6 +39,11 @@ class SimpleAPI { logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode) guard 200...299 ~= httpResponse.statusCode else { + if httpResponse.statusCode == 401 { + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil) + } + } let msg = String(data: data, encoding: .utf8) ?? "Unknown error" logger.error("Server error \(httpResponse.statusCode): \(msg)") showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true) @@ -87,6 +92,11 @@ class SimpleAPI { logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode) guard 200...299 ~= httpResponse.statusCode else { + if httpResponse.statusCode == 401 { + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil) + } + } let msg = String(data: data, encoding: .utf8) ?? "Unknown error" logger.error("Server error \(httpResponse.statusCode): \(msg)") showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true) diff --git a/documentation/401.md b/documentation/401.md new file mode 100644 index 0000000..47e0f0e --- /dev/null +++ b/documentation/401.md @@ -0,0 +1,41 @@ +# Feature: Persistentes Logout bei 401 Unauthorized + +## Problemstellung +Wenn eine API-Anfrage mit einem `401 Unauthorized`-Response fehlschlägt, bedeutet dies, dass der aktuell gespeicherte Token oder die Session des Nutzers ungültig ist (z. B. durch manuelles Löschen des Tokens im Backend oder andere Ursachen). +In diesem Zustand darf der User nicht weiter mit einer scheinbar gültigen Session in der App interagieren. + +## Ziel +Die App soll den Nutzer in einem solchen Fall automatisch vollständig **ausloggen** und auf den **Setup-/Login-Screen** umleiten. +Dies muss **persistiert** sein, d. h. auch nach App-Neustart darf der Nutzer nicht eingeloggt zurückkehren, solange keine erfolgreiche neue Anmeldung durchgeführt wurde. + +--- + +## Anforderungen + +1. **Erkennen von ungültigem Token** + - Jede API-Antwort mit `401 Unauthorized` löst den Logout-Prozess aus. + - Optional: Kontext beachten (z. B. ob der Request ein Refresh-Token war). + +2. **Logout-Mechanismus** + - Alle gespeicherten Zugangsdaten (Access Token, Refresh Token, User-Daten im Keychain/Storage) werden gelöscht. + - UI-State wird in den "nicht eingeloggten" Zustand zurückversetzt. + - Persistenter "loggedOut"-State wird gesetzt (z. B. in `UserDefaults` oder einer App-State-DB). + +3. **Persistenz** + - Falls der Nutzer die App neu startet, startet er im Setup-/Login-Screen und nicht in einem alten Session-Kontext. + +4. **Wiederanmeldung** + - Sobald der Nutzer sich neu einloggt und erfolgreich ein Access Token erhält: + - wird der persistente "loggedOut"-State zurückgesetzt + - die App verhält sich wieder wie gewohnt im eingeloggten Zustand. + +--- + +## Beispiel-Use Case +- User ist eingeloggt in die App. +- Im Backend wird manuell der Token gelöscht oder die Session invalidiert. +- Nächster API-Call → API gibt `401 Unauthorized` zurück. +- App erkennt ungültigen Zustand, **löscht alle Tokens und Session-Daten** und leitet den User sofort auf den **Setup-/Login-Screen** um. +- Auch nach einem App-Neustart startet der User weiterhin im Setup-Screen. +- Sobald der User sich erfolgreich einloggt, gelten alle API-Calls wieder normal. + diff --git a/documentation/tabbar.md b/documentation/tabbar.md new file mode 100644 index 0000000..662b76e --- /dev/null +++ b/documentation/tabbar.md @@ -0,0 +1,18 @@ +## Feature: TabView nur in Root-Screens sichtbar, nicht in Detail-Views + +### Beschreibung +Die App verwendet aktuell eine `TabView` (bzw. einen Tab Controller), die global sichtbar ist. +Der Workflow funktioniert grundsätzlich, allerdings wird die `TabView` auch dann angezeigt, wenn ein Benutzer von einem Tab in eine **Detailansicht** (z. B. Artikel-Detail, Item-Detail) navigiert. + +Ziel ist es, dass die `TabView` **nur in den Root-Views** sichtbar ist. +Beim Öffnen einer **Detail-Ansicht** soll die `TabView` automatisch ausgeblendet werden, damit dort alternativ eine eigene **Bottom Toolbar** angezeigt werden kann. + +### Akzeptanzkriterien +- `TabView` ist standardmäßig in den Haupt-Tabs sichtbar. +- Navigiert der Nutzer in eine Detail-Ansicht (z. B. Rom Detail), wird die `TabView` ausgeblendet. +- In den Detail-Ansichten kann ein eigener `Toolbar` oder eine Custom Bottom Bar sichtbar sein - ist aber kein teil von diesem task. +- Navigation zurück zur Root-View blendet die `TabView` wieder ein. + +# Technischer hinweis + +To hide TabBar when we jumps towards next screen we just have to place NavigationView to the right place. Makesure Embed TabView inside NavigationView so creating unique Navigationview for both tabs. \ No newline at end of file diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 1ff8466..36856fb 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -41,6 +41,14 @@ class API: PAPI { return url } } + + private func handleUnauthorizedResponse(_ statusCode: Int) { + if statusCode == 401 { + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil) + } + } + } private func makeJSONRequestWithHeaders( endpoint: String, @@ -74,6 +82,7 @@ class API: PAPI { } guard 200...299 ~= httpResponse.statusCode else { + handleUnauthorizedResponse(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode) } @@ -114,6 +123,7 @@ class API: PAPI { } guard 200...299 ~= httpResponse.statusCode else { + handleUnauthorizedResponse(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode) } @@ -146,6 +156,7 @@ class API: PAPI { } guard 200...299 ~= httpResponse.statusCode else { + handleUnauthorizedResponse(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode) } @@ -181,6 +192,7 @@ class API: PAPI { } guard 200...299 ~= httpResponse.statusCode else { + handleUnauthorizedResponse(httpResponse.statusCode) logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) throw APIError.serverError(httpResponse.statusCode) } @@ -342,6 +354,7 @@ class API: PAPI { } guard 200...299 ~= httpResponse.statusCode else { + handleUnauthorizedResponse(httpResponse.statusCode) logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) throw APIError.serverError(httpResponse.statusCode) } @@ -379,6 +392,7 @@ class API: PAPI { } guard 200...299 ~= httpResponse.statusCode else { + handleUnauthorizedResponse(httpResponse.statusCode) logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) throw APIError.serverError(httpResponse.statusCode) } diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift new file mode 100644 index 0000000..60f818b --- /dev/null +++ b/readeck/UI/AppViewModel.swift @@ -0,0 +1,71 @@ +// +// AppViewModel.swift +// readeck +// +// Created by Ilyas Hallak on 27.08.25. +// + +import Foundation +import SwiftUI + +class AppViewModel: ObservableObject { + private let settingsRepository = SettingsRepository() + private let logoutUseCase: LogoutUseCase + + @Published var hasFinishedSetup: Bool = true + + init(logoutUseCase: LogoutUseCase = LogoutUseCase()) { + self.logoutUseCase = logoutUseCase + setupNotificationObservers() + + Task { + await loadSetupStatus() + } + } + + private func setupNotificationObservers() { + NotificationCenter.default.addObserver( + forName: NSNotification.Name("UnauthorizedAPIResponse"), + object: nil, + queue: .main + ) { [weak self] _ in + Task { + await self?.handleUnauthorizedResponse() + } + } + + NotificationCenter.default.addObserver( + forName: NSNotification.Name("SetupStatusChanged"), + object: nil, + queue: .main + ) { [weak self] _ in + self?.loadSetupStatus() + } + } + + @MainActor + private func handleUnauthorizedResponse() async { + print("AppViewModel: Handling 401 Unauthorized - logging out user") + + do { + // Führe den Logout durch + try await logoutUseCase.execute() + + // Update UI state + loadSetupStatus() + + print("AppViewModel: User successfully logged out due to 401 error") + } catch { + print("AppViewModel: Error during logout: \(error)") + } + } + + @MainActor + private func loadSetupStatus() { + hasFinishedSetup = settingsRepository.hasFinishedSetup + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index 99c23ff..9c4142a 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -18,12 +18,14 @@ struct PhoneTabView: View { @EnvironmentObject var appSettings: AppSettings var body: some View { - GlobalPlayerContainerView { - TabView(selection: $selectedTabIndex) { - mainTabsContent - moreTabContent + NavigationStack { + GlobalPlayerContainerView { + TabView(selection: $selectedTabIndex) { + mainTabsContent + moreTabContent + } + .accentColor(.accentColor) } - .accentColor(.accentColor) } } @@ -34,23 +36,19 @@ struct PhoneTabView: View { @ViewBuilder private var mainTabsContent: some View { ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in - NavigationStack { - tabView(for: tab) - } - .tabItem { - Label(tab.label, systemImage: tab.systemImage) - } - .tag(idx) + tabView(for: tab) + .tabItem { + Label(tab.label, systemImage: tab.systemImage) + } + .tag(idx) } } @ViewBuilder private var moreTabContent: some View { - NavigationStack { - VStack(spacing: 0) { - moreTabsList - moreTabsFooter - } + VStack(spacing: 0) { + moreTabsList + moreTabsFooter } .tabItem { Label("More", systemImage: "ellipsis") @@ -71,6 +69,7 @@ struct PhoneTabView: View { NavigationLink { tabView(for: tab) .navigationTitle(tab.label) + .navigationBarTitleDisplayMode(.large) .onDisappear { // tags and search handle navigation by own if tab != .tags && tab != .search { diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index ef045da..b87d659 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -10,13 +10,13 @@ import netfox @main struct readeckApp: App { - @State private var hasFinishedSetup = true + @StateObject private var appViewModel = AppViewModel() @StateObject private var appSettings = AppSettings() var body: some Scene { WindowGroup { Group { - if hasFinishedSetup { + if appViewModel.hasFinishedSetup { MainTabView() } else { SettingsServerView() @@ -32,25 +32,19 @@ struct readeckApp: App { // Initialize server connectivity monitoring _ = ServerConnectivity.shared Task { - await loadSetupStatus() - } - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in - Task { - await loadSetupStatus() + await loadAppSettings() } } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in Task { - await loadSetupStatus() + await loadAppSettings() } } } } - private func loadSetupStatus() async { + private func loadAppSettings() async { let settingsRepository = SettingsRepository() - hasFinishedSetup = settingsRepository.hasFinishedSetup let settings = try? await settingsRepository.loadSettings() await MainActor.run { appSettings.settings = settings