Compare commits

...

6 Commits

Author SHA1 Message Date
5b520995ac typo 2025-09-04 12:14:44 +02:00
8fb2a2a14e fix: Add circular progress for delete countdown 2025-09-04 12:14:20 +02:00
df8a7b64b2 feat: Add Kingfisher caching, card layouts, dynamic tag layout, and undo delete
- Integrate Kingfisher for image caching with CachedAsyncImage component
- Add CacheSettingsView for managing image cache size and clearing cache
- Implement three card layout styles: compact, magazine (default), natural
- Add AppearanceSettingsView with visual layout previews and theme settings
- Create Clean Architecture for card layout with domain models and use cases
- Implement FlowLayout for dynamic label width calculation
- Add skeleton loading animation for initial bookmark loads
- Replace delete confirmation dialogs with immediate delete + 3-second undo
- Support multiple simultaneous undo operations with individual progress bars
- Add grayed-out visual feedback for pending deletions
- Centralize notification names in dedicated NotificationNames file
- Remove pagination logic from label management (replaced with FlowLayout)
- Update AsyncImage usage across BookmarkCardView, BookmarkDetailView, ImageViewerView
- Improve UI consistency and spacing throughout the app
2025-09-04 10:43:27 +02:00
680a9562be updated gitignore 2025-08-29 21:07:54 +02:00
2f55da92c0 feat: Convert localization from xcstrings to traditional .strings format
- Migrate from Localizable.xcstrings to .lproj structure for Weblate compatibility
- Create Base, English, and German localization directories
- Update Xcode project configuration to use .strings files
- Clean up unused xcstrings references from project file
2025-08-28 18:50:11 +02:00
953ff5da8d feat: Implement persistent logout on 401 errors and hide TabBar in detail views
- Add AppViewModel to manage app-level state and handle 401 responses
- Implement automatic logout when API returns 401 Unauthorized
- Add persistent logout state using existing hasFinishedSetup flag
- Move NavigationStack outside TabView to enable automatic TabBar hiding
- Update API classes to send UnauthorizedAPIResponse notifications
- TabBar now hides automatically when navigating to detail views
2025-08-27 22:04:37 +02:00
50 changed files with 1951 additions and 472 deletions

3
.gitignore vendored
View File

@ -63,3 +63,6 @@ fastlane/screenshots/**/*.png
fastlane/test_output
fastlane/.env.default
fastlane/AuthKey_JZJCQWW9N3.p8
# Documentation
documentation/

View File

@ -6,7 +6,7 @@ struct ShareBookmarkView: View {
@FocusState private var focusedField: AddBookmarkFieldFocus?
private func dismissKeyboard() {
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
}
var body: some View {
@ -140,7 +140,6 @@ struct ShareBookmarkView: View {
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: false,
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
searchFieldFocus: $focusedField,
onAddCustomTag: {

View File

@ -15,13 +15,12 @@ class ShareBookmarkViewModel: ObservableObject {
let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel
// Computed properties for pagination
var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) }
}
// Computed property for filtered labels based on search text
// filtered labels based on search text
var filteredLabels: [BookmarkLabelDto] {
if searchText.isEmpty {
return availableLabels

View File

@ -34,7 +34,7 @@ class ShareViewController: UIViewController {
NotificationCenter.default.addObserver(
self,
selector: #selector(dismissKeyboard),
name: NSNotification.Name("DismissKeyboard"),
name: .dismissKeyboard,
object: nil
)
}

View File

@ -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: .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: .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)

41
documentation/401.md Normal file
View File

@ -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.

18
documentation/tabbar.md Normal file
View File

@ -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.

View File

@ -9,9 +9,8 @@
/* Begin PBXBuildFile section */
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -67,7 +66,6 @@
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -93,6 +91,7 @@
UI/Components/CustomTextFieldStyle.swift,
UI/Components/TagManagementView.swift,
UI/Components/UnifiedLabelChip.swift,
UI/Utils/NotificationNames.swift,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};
@ -147,6 +146,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
);
@ -172,7 +172,6 @@
5D45F9BF2DF858680048D5B8 = {
isa = PBXGroup;
children = (
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
5D45F9CA2DF858680048D5B8 /* readeck */,
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
@ -240,6 +239,7 @@
packageProductDependencies = (
5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5D9D95482E623668009AF769 /* Kingfisher */,
);
productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -330,6 +330,7 @@
packageReferences = (
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -349,7 +350,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -357,7 +357,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -436,7 +435,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -469,7 +468,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -624,7 +623,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -668,7 +667,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -853,6 +852,14 @@
minimumVersion = 1.21.0;
};
};
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 8.5.0;
};
};
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
@ -869,6 +876,11 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = netfox;
};
5D9D95482E623668009AF769 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;

View File

@ -1,6 +1,15 @@
{
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
"originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
"pins" : [
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "2015fda791daa72c8058619545a593bf8c1dd59f",
"version" : "8.5.0"
}
},
{
"identity" : "netfox",
"kind" : "remoteSourceControl",

View File

@ -2,16 +2,7 @@
"images" : [
{
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
"idiom" : "universal"
}
],
"info" : {

View File

@ -41,6 +41,14 @@ class API: PAPI {
return url
}
}
private func handleUnauthorizedResponse(_ statusCode: Int) {
if statusCode == 401 {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
}
}
}
private func makeJSONRequestWithHeaders<T: Codable>(
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)
}

View File

@ -119,7 +119,7 @@ class OfflineSyncManager: ObservableObject {
func startAutoSync() {
// Monitor server connectivity and auto-sync when server becomes reachable
NotificationCenter.default.addObserver(
forName: NSNotification.Name("ServerDidBecomeAvailable"),
forName: .serverDidBecomeAvailable,
object: nil,
queue: .main
) { [weak self] _ in

View File

@ -12,6 +12,7 @@ struct Settings {
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
@ -31,6 +32,8 @@ protocol PSettingsRepository {
func savePassword(_ password: String) async throws
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
func loadCardLayoutStyle() async throws -> CardLayoutStyle
var hasFinishedSetup: Bool { get }
}
@ -79,6 +82,10 @@ class SettingsRepository: PSettingsRepository {
existingSettings.theme = theme.rawValue
}
if let cardLayoutStyle = settings.cardLayoutStyle {
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
}
try context.save()
continuation.resume()
} catch {
@ -115,7 +122,8 @@ class SettingsRepository: PSettingsRepository {
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
enableTTS: settingEntity?.enableTTS,
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue)
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue)
)
continuation.resume(returning: settings)
} catch {
@ -160,7 +168,7 @@ class SettingsRepository: PSettingsRepository {
self.hasFinishedSetup = true
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
}
}
@ -174,7 +182,7 @@ class SettingsRepository: PSettingsRepository {
if !token.isEmpty {
self.hasFinishedSetup = true
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
}
}
@ -192,7 +200,7 @@ class SettingsRepository: PSettingsRepository {
self.hasFinishedSetup = hasFinishedSetup
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
continuation.resume()
}
@ -206,4 +214,45 @@ class SettingsRepository: PSettingsRepository {
userDefault.set(newValue, forKey: "hasFinishedSetup")
}
}
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func loadCardLayoutStyle() async throws -> CardLayoutStyle {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first
let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine
continuation.resume(returning: cardLayoutStyle)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}

View File

@ -25,7 +25,7 @@ class ServerConnectivity: ObservableObject {
// Notify when server becomes available
if !wasReachable && serverReachable {
NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil)
NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil)
}
}
}

View File

@ -0,0 +1,29 @@
import Foundation
enum CardLayoutStyle: String, CaseIterable, Codable {
case compact = "compact"
case magazine = "magazine"
case natural = "natural"
var displayName: String {
switch self {
case .compact:
return "Compact"
case .magazine:
return "Magazine"
case .natural:
return "Natural"
}
}
var description: String {
switch self {
case .compact:
return "Small thumbnails with content focus"
case .magazine:
return "Fixed height headers for consistent layout"
case .natural:
return "Images in original aspect ratio"
}
}
}

View File

@ -0,0 +1,21 @@
import Foundation
protocol PLoadCardLayoutUseCase {
func execute() async -> CardLayoutStyle
}
class LoadCardLayoutUseCase: PLoadCardLayoutUseCase {
private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute() async -> CardLayoutStyle {
do {
return try await settingsRepository.loadCardLayoutStyle()
} catch {
return .magazine
}
}
}

View File

@ -0,0 +1,22 @@
import Foundation
protocol PSaveCardLayoutUseCase {
func execute(layout: CardLayoutStyle) async
}
class SaveCardLayoutUseCase: PSaveCardLayoutUseCase {
private let settingsRepository: PSettingsRepository
private let logger = Logger.data
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute(layout: CardLayoutStyle) async {
do {
try await settingsRepository.saveCardLayoutStyle(layout)
} catch {
logger.error("Failed to save card layout style: \(error)")
}
}
}

View File

@ -0,0 +1,127 @@
/*
Localizable.strings
readeck
Created by conversion from Localizable.xcstrings
*/
"" = "";
"(%lld found)" = "(%lld found)";
"%" = "%";
"%@ (%lld)" = "%1$@ (%2$lld)";
"%lld" = "%lld";
"%lld articles in the queue" = "%lld articles in the queue";
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
"%lld min" = "%lld min";
"%lld." = "%lld.";
"%lld/%lld" = "%1$lld/%2$lld";
"12 min • Today • example.com" = "12 min • Today • example.com";
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
"Add" = "Add";
"Add new tag:" = "Add new tag:";
"all" = "all";
"All tags selected" = "All tags selected";
"Archive" = "Archive";
"Archive bookmark" = "Archive bookmark";
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
"Available tags" = "Available tags";
"Cancel" = "Cancel";
"Category-specific Levels" = "Category-specific Levels";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
"Close" = "Close";
"Configure log levels and categories" = "Configure log levels and categories";
"Critical" = "Critical";
"Debug" = "Debug";
"DEBUG BUILD" = "DEBUG BUILD";
"Debug Settings" = "Debug Settings";
"Delete" = "Delete";
"Delete Bookmark" = "Delete Bookmark";
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
"Done" = "Done";
"Enter an optional title..." = "Enter an optional title...";
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
"Error" = "Error";
"Error: %@" = "Error: %@";
"Favorite" = "Favorite";
"Finished reading?" = "Finished reading?";
"Font" = "Font";
"Font family" = "Font family";
"Font Settings" = "Font Settings";
"Font size" = "Font size";
"From Bremen with 💚" = "From Bremen with 💚";
"General" = "General";
"Global Level" = "Global Level";
"Global Minimum Level" = "Global Minimum Level";
"Global Settings" = "Global Settings";
"https://example.com" = "https://example.com";
"https://readeck.example.com" = "https://readeck.example.com";
"Include Source Location" = "Include Source Location";
"Info" = "Info";
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
"Key" = "Key";
"Level for %@" = "Level for %@";
"Loading %@" = "Loading %@";
"Loading article..." = "Loading article...";
"Logging Configuration" = "Logging Configuration";
"Login & Save" = "Login & Save";
"Logout" = "Logout";
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
"Manage Labels" = "Manage Labels";
"Mark as favorite" = "Mark as favorite";
"More" = "More";
"New Bookmark" = "New Bookmark";
"No articles in the queue" = "No articles in the queue";
"No bookmarks" = "No bookmarks";
"No bookmarks found in %@." = "No bookmarks found in %@.";
"No bookmarks found." = "No bookmarks found.";
"No results" = "No results";
"Notice" = "Notice";
"OK" = "OK";
"Optional: Custom title" = "Optional: Custom title";
"Password" = "Password";
"Paste" = "Paste";
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
"Preview" = "Preview";
"Progress: %lld%%" = "Progress: %lld%%";
"Re-login & Save" = "Re-login & Save";
"Read Aloud Feature" = "Read Aloud Feature";
"Read article aloud" = "Read article aloud";
"Read-aloud Queue" = "Read-aloud Queue";
"readeck Bookmark Title" = "readeck Bookmark Title";
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
"Remove" = "Remove";
"Reset" = "Reset";
"Reset to Defaults" = "Reset to Defaults";
"Restore" = "Restore";
"Resume listening" = "Resume listening";
"Save bookmark" = "Save bookmark";
"Save Bookmark" = "Save Bookmark";
"Saving..." = "Saving...";
"Search" = "Search";
"Search or add new tag..." = "Search or add new tag...";
"Search results" = "Search results";
"Search..." = "Search...";
"Searching..." = "Searching...";
"Select a bookmark or tag" = "Select a bookmark or tag";
"Selected tags" = "Selected tags";
"Server Endpoint" = "Server Endpoint";
"Server not reachable - saving locally" = "Server not reachable - saving locally";
"Settings" = "Settings";
"Show Performance Logs" = "Show Performance Logs";
"Show Timestamps" = "Show Timestamps";
"Speed" = "Speed";
"Syncing with server..." = "Syncing with server...";
"Theme" = "Theme";
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
"Try Again" = "Try Again";
"Unable to load bookmarks" = "Unable to load bookmarks";
"Unarchive Bookmark" = "Unarchive Bookmark";
"URL in clipboard:" = "URL in clipboard:";
"Username" = "Username";
"Version %@" = "Version %@";
"Warning" = "Warning";
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
"Your Password" = "Your Password";
"Your Username" = "Your Username";

View File

@ -0,0 +1,8 @@
/*
Localizable.strings (German)
readeck
Created by conversion from Localizable.xcstrings
*/
"all" = "Ale";

View File

@ -0,0 +1,127 @@
/*
Localizable.strings
readeck
Created by conversion from Localizable.xcstrings
*/
"" = "";
"(%lld found)" = "(%lld found)";
"%" = "%";
"%@ (%lld)" = "%1$@ (%2$lld)";
"%lld" = "%lld";
"%lld articles in the queue" = "%lld articles in the queue";
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
"%lld min" = "%lld min";
"%lld." = "%lld.";
"%lld/%lld" = "%1$lld/%2$lld";
"12 min • Today • example.com" = "12 min • Today • example.com";
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
"Add" = "Add";
"Add new tag:" = "Add new tag:";
"all" = "all";
"All tags selected" = "All tags selected";
"Archive" = "Archive";
"Archive bookmark" = "Archive bookmark";
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
"Available tags" = "Available tags";
"Cancel" = "Cancel";
"Category-specific Levels" = "Category-specific Levels";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
"Close" = "Close";
"Configure log levels and categories" = "Configure log levels and categories";
"Critical" = "Critical";
"Debug" = "Debug";
"DEBUG BUILD" = "DEBUG BUILD";
"Debug Settings" = "Debug Settings";
"Delete" = "Delete";
"Delete Bookmark" = "Delete Bookmark";
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
"Done" = "Done";
"Enter an optional title..." = "Enter an optional title...";
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
"Error" = "Error";
"Error: %@" = "Error: %@";
"Favorite" = "Favorite";
"Finished reading?" = "Finished reading?";
"Font" = "Font";
"Font family" = "Font family";
"Font Settings" = "Font Settings";
"Font size" = "Font size";
"From Bremen with 💚" = "From Bremen with 💚";
"General" = "General";
"Global Level" = "Global Level";
"Global Minimum Level" = "Global Minimum Level";
"Global Settings" = "Global Settings";
"https://example.com" = "https://example.com";
"https://readeck.example.com" = "https://readeck.example.com";
"Include Source Location" = "Include Source Location";
"Info" = "Info";
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
"Key" = "Key";
"Level for %@" = "Level for %@";
"Loading %@" = "Loading %@";
"Loading article..." = "Loading article...";
"Logging Configuration" = "Logging Configuration";
"Login & Save" = "Login & Save";
"Logout" = "Logout";
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
"Manage Labels" = "Manage Labels";
"Mark as favorite" = "Mark as favorite";
"More" = "More";
"New Bookmark" = "New Bookmark";
"No articles in the queue" = "No articles in the queue";
"No bookmarks" = "No bookmarks";
"No bookmarks found in %@." = "No bookmarks found in %@.";
"No bookmarks found." = "No bookmarks found.";
"No results" = "No results";
"Notice" = "Notice";
"OK" = "OK";
"Optional: Custom title" = "Optional: Custom title";
"Password" = "Password";
"Paste" = "Paste";
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
"Preview" = "Preview";
"Progress: %lld%%" = "Progress: %lld%%";
"Re-login & Save" = "Re-login & Save";
"Read Aloud Feature" = "Read Aloud Feature";
"Read article aloud" = "Read article aloud";
"Read-aloud Queue" = "Read-aloud Queue";
"readeck Bookmark Title" = "readeck Bookmark Title";
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
"Remove" = "Remove";
"Reset" = "Reset";
"Reset to Defaults" = "Reset to Defaults";
"Restore" = "Restore";
"Resume listening" = "Resume listening";
"Save bookmark" = "Save bookmark";
"Save Bookmark" = "Save Bookmark";
"Saving..." = "Saving...";
"Search" = "Search";
"Search or add new tag..." = "Search or add new tag...";
"Search results" = "Search results";
"Search..." = "Search...";
"Searching..." = "Searching...";
"Select a bookmark or tag" = "Select a bookmark or tag";
"Selected tags" = "Selected tags";
"Server Endpoint" = "Server Endpoint";
"Server not reachable - saving locally" = "Server not reachable - saving locally";
"Settings" = "Settings";
"Show Performance Logs" = "Show Performance Logs";
"Show Timestamps" = "Show Timestamps";
"Speed" = "Speed";
"Syncing with server..." = "Syncing with server...";
"Theme" = "Theme";
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
"Try Again" = "Try Again";
"Unable to load bookmarks" = "Unable to load bookmarks";
"Unarchive Bookmark" = "Unarchive Bookmark";
"URL in clipboard:" = "URL in clipboard:";
"Username" = "Username";
"Version %@" = "Version %@";
"Warning" = "Warning";
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
"Your Password" = "Your Password";
"Your Username" = "Your Username";

View File

@ -74,11 +74,9 @@ struct AddBookmarkView: View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 20) {
VStack(spacing: 20) {
VStack(spacing: 16) {
urlField
.id("urlField")
Spacer()
.frame(height: 40)
.id("labelsOffset")
labelsField
.id("labelsField")
@ -160,10 +158,11 @@ struct AddBookmarkView: View {
}
}
}
.padding()
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 8))
.transition(.opacity.combined(with: .move(edge: .top)))
.padding(.top, 4)
}
}
@ -183,7 +182,6 @@ struct AddBookmarkView: View {
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: viewModel.isLabelsLoading,
availableLabelPages: viewModel.availableLabelPages,
filteredLabels: viewModel.filteredLabels,
searchFieldFocus: $focusedField,
onAddCustomTag: {

View File

@ -59,19 +59,6 @@ class AddBookmarkViewModel {
}
}
var availableLabelPages: [[BookmarkLabel]] {
let pageSize = Constants.Labels.pageSize
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
if labelsToShow.count <= pageSize {
return [labelsToShow]
} else {
return stride(from: 0, to: labelsToShow.count, by: pageSize).map {
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
}
}
}
// MARK: - Labels Management
@MainActor

View File

@ -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: .unauthorizedAPIResponse,
object: nil,
queue: .main
) { [weak self] _ in
Task {
await self?.handleUnauthorizedResponse()
}
}
NotificationCenter.default.addObserver(
forName: .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)
}
}

View File

@ -4,7 +4,6 @@ import Combine
struct BookmarkDetailView: View {
let bookmarkId: String
let namespace: Namespace.ID?
// MARK: - States
@ -24,11 +23,10 @@ struct BookmarkDetailView: View {
@EnvironmentObject var appSettings: AppSettings
@Environment(\.dismiss) private var dismiss
private let headerHeight: CGFloat = 320
private let headerHeight: CGFloat = 360
init(bookmarkId: String, namespace: Namespace.ID? = nil, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId
self.namespace = namespace
self.viewModel = viewModel
self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings
@ -66,7 +64,7 @@ struct BookmarkDetailView: View {
})
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal)
.padding(.horizontal, 4)
.animation(.easeInOut, value: webViewHeight)
} else if viewModel.isLoadingArticle {
ProgressView("Loading article...")
@ -78,7 +76,7 @@ struct BookmarkDetailView: View {
}) {
HStack {
Image(systemName: "safari")
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
@ -191,40 +189,11 @@ struct BookmarkDetailView: View {
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
ZStack(alignment: .top) {
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
image
.resizable()
.scaledToFill()
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.scaledToFit()
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
.clipped()
.offset(y: (offset > 0 ? -offset : 0))
.if(namespace != nil) { view in
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
}
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.4))
.frame(width: geometry.size.width, height: headerHeight)
.if(namespace != nil) { view in
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
}
}
// Gradient overlay für bessere Button-Sichtbarkeit
LinearGradient(
gradient: Gradient(colors: [
Color.black.opacity(1.0),
Color.black.opacity(0.9),
Color.black.opacity(0.7),
Color.black.opacity(0.4),
Color.black.opacity(0.2),
Color.clear
]),
startPoint: .top,
endPoint: .bottom
)
.frame(height: 240)
.frame(maxWidth: .infinity)
.offset(y: (offset > 0 ? -offset : 0))
.offset(y: (offset > 0 ? -offset : 0))
// Tap area and zoom icon
VStack {

View File

@ -61,7 +61,6 @@ struct BookmarkLabelsView: View {
selectedLabels: Set(viewModel.currentLabels),
searchText: $viewModel.searchText,
isLabelsLoading: viewModel.isInitialLoading,
availableLabelPages: viewModel.availableLabelPages,
filteredLabels: viewModel.filteredLabels,
onAddCustomTag: {
Task {

View File

@ -10,46 +10,24 @@ class BookmarkLabelsViewModel {
var isInitialLoading = false
var errorMessage: String?
var showErrorAlert = false
var currentLabels: [String] = [] {
didSet {
if oldValue != currentLabels {
calculatePages()
}
}
}
var currentLabels: [String] = []
var newLabelText = ""
var searchText = "" {
didSet {
if oldValue != searchText {
calculatePages()
}
}
}
var searchText = ""
var allLabels: [BookmarkLabel] = [] {
didSet {
if oldValue != allLabels {
calculatePages()
}
}
}
var labelPages: [[BookmarkLabel]] = []
// Cached properties to avoid recomputation
private var _availableLabels: [BookmarkLabel] = []
private var _filteredLabels: [BookmarkLabel] = []
var allLabels: [BookmarkLabel] = []
var availableLabels: [BookmarkLabel] {
return _availableLabels
return allLabels.filter { !currentLabels.contains($0.name) }
}
var filteredLabels: [BookmarkLabel] {
return _filteredLabels
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var availableLabelPages: [[BookmarkLabel]] = []
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
self.currentLabels = initialLabels
@ -70,8 +48,6 @@ class BookmarkLabelsViewModel {
errorMessage = "failed to load labels"
showErrorAlert = true
}
calculatePages()
}
@MainActor
@ -143,36 +119,4 @@ class BookmarkLabelsViewModel {
func updateLabels(_ labels: [String]) {
currentLabels = labels
}
private func calculatePages() {
let pageSize = Constants.Labels.pageSize
// Update cached available labels
_availableLabels = allLabels.filter { !currentLabels.contains($0.name) }
// Update cached filtered labels
if searchText.isEmpty {
_filteredLabels = _availableLabels
} else {
_filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
// Calculate pages for all labels
if allLabels.count <= pageSize {
labelPages = [allLabels]
} else {
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
}
}
// Calculate pages for filtered labels
if _filteredLabels.count <= pageSize {
availableLabelPages = [_filteredLabels]
} else {
availableLabelPages = stride(from: 0, to: _filteredLabels.count, by: pageSize).map {
Array(_filteredLabels[$0..<min($0 + pageSize, _filteredLabels.count)])
}
}
}
}

View File

@ -17,102 +17,91 @@ struct ImageViewerView: View {
Color.black
.ignoresSafeArea()
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(offset)
.offset(dragOffset)
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = min(max(scale * delta, 1), 4)
}
.onEnded { _ in
lastScale = 1.0
if scale < 1 {
withAnimation(.spring()) {
scale = 1
offset = .zero
}
}
if scale > 4 {
scale = 4
}
},
DragGesture()
.onChanged { value in
if scale > 1 {
let newOffset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
offset = newOffset
} else {
// Dismiss gesture when not zoomed
dragOffset = value.translation
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
if dragDistance > 50 {
isDraggingToDismiss = true
}
}
}
.onEnded { value in
if scale <= 1 {
lastOffset = offset
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
if dragDistance > 100 || velocity > 500 {
dismiss()
} else {
withAnimation(.spring()) {
dragOffset = .zero
isDraggingToDismiss = false
}
}
} else {
lastOffset = offset
}
}
)
)
.onTapGesture(count: 2) {
withAnimation(.spring()) {
if scale > 1 {
scale = 1
offset = .zero
lastOffset = .zero
} else {
scale = 2
CachedAsyncImage(url: URL(string: imageUrl))
.scaledToFit()
.scaleEffect(scale)
.offset(offset)
.offset(dragOffset)
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = min(max(scale * delta, 1), 4)
}
.onEnded { _ in
lastScale = 1.0
if scale < 1 {
withAnimation(.spring()) {
scale = 1
offset = .zero
}
}
if scale > 4 {
scale = 4
}
},
DragGesture()
.onChanged { value in
if scale > 1 {
let newOffset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
offset = newOffset
} else {
// Dismiss gesture when not zoomed
dragOffset = value.translation
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
if dragDistance > 50 {
isDraggingToDismiss = true
}
}
}
.onEnded { value in
if scale <= 1 {
lastOffset = offset
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
if dragDistance > 100 || velocity > 500 {
dismiss()
} else {
withAnimation(.spring()) {
dragOffset = .zero
isDraggingToDismiss = false
}
}
} else {
lastOffset = offset
}
}
)
)
.onTapGesture(count: 2) {
withAnimation(.spring()) {
if scale > 1 {
scale = 1
offset = .zero
lastOffset = .zero
} else {
scale = 2
}
}
} placeholder: {
ProgressView()
.scaleEffect(1.5)
.foregroundColor(.white)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
.foregroundColor(.white)
}
.foregroundColor(.white)
}
}
}
}
}
#Preview {
ImageViewerView(imageUrl: "https://example.com/image.jpg")
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import Foundation
import SafariServices
extension View {
@ -12,35 +13,191 @@ extension View {
}
struct BookmarkCardView: View {
@Environment(\.colorScheme) var colorScheme
let bookmark: Bookmark
let currentState: BookmarkState
let layout: CardLayoutStyle
let pendingDelete: PendingDelete?
let onArchive: (Bookmark) -> Void
let onDelete: (Bookmark) -> Void
let onToggleFavorite: (Bookmark) -> Void
let namespace: Namespace.ID?
let onUndoDelete: ((String) -> Void)?
init(
bookmark: Bookmark,
currentState: BookmarkState,
layout: CardLayoutStyle = .magazine,
pendingDelete: PendingDelete? = nil,
onArchive: @escaping (Bookmark) -> Void,
onDelete: @escaping (Bookmark) -> Void,
onToggleFavorite: @escaping (Bookmark) -> Void,
onUndoDelete: ((String) -> Void)? = nil
) {
self.bookmark = bookmark
self.currentState = currentState
self.layout = layout
self.pendingDelete = pendingDelete
self.onArchive = onArchive
self.onDelete = onDelete
self.onToggleFavorite = onToggleFavorite
self.onUndoDelete = onUndoDelete
}
var body: some View {
ZStack(alignment: .bottom) {
Group {
switch layout {
case .compact:
compactLayoutView
case .magazine:
magazineLayoutView
case .natural:
naturalLayoutView
}
}
.opacity(pendingDelete != nil ? 0.4 : 1.0)
.animation(.easeInOut(duration: 0.2), value: pendingDelete != nil)
// Undo toast overlay with progress background
if let pendingDelete = pendingDelete {
VStack(spacing: 0) {
Spacer()
// Undo button area with circular progress
HStack {
HStack(spacing: 8) {
// Circular progress indicator
ZStack {
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: 2)
.frame(width: 16, height: 16)
Circle()
.trim(from: 0, to: CGFloat(pendingDelete.progress))
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.animation(.linear(duration: 0.1), value: pendingDelete.progress)
}
Text("Deleting...")
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
Button("Undo") {
onUndoDelete?(bookmark.id)
}
.font(.caption.weight(.medium))
.foregroundColor(.blue)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
.onTapGesture {
onUndoDelete?(bookmark.id)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemBackground).opacity(0.95))
}
.clipShape(RoundedRectangle(cornerRadius: layout == .compact ? 8 : 12))
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if pendingDelete == nil {
Button("Delete", role: .destructive) {
onDelete(bookmark)
}
.tint(.red)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if pendingDelete == nil {
Button {
onArchive(bookmark)
} label: {
if currentState == .archived {
Label("Restore", systemImage: "tray.and.arrow.up")
} else {
Label("Archive", systemImage: "archivebox")
}
}
.tint(currentState == .archived ? .blue : .orange)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Remove" : "Favorite",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
}
.tint(bookmark.isMarked ? .gray : .pink)
}
}
}
private var compactLayoutView: some View {
HStack(alignment: .top, spacing: 12) {
CachedAsyncImage(url: imageURL)
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
if !bookmark.description.isEmpty {
Text(bookmark.description)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
}
HStack(spacing: 4) {
if !bookmark.siteName.isEmpty {
HStack(spacing: 2) {
Image(systemName: "globe")
Text(bookmark.siteName)
}
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if let readingTime = bookmark.readingTime, readingTime > 0 {
HStack(spacing: 2) {
Image(systemName: "clock")
Text("\(readingTime) min")
}
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.padding(12)
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var magazineLayoutView: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottomTrailing) {
AsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
} placeholder: {
Image(R.image.placeholder.name)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.if(namespace != nil) { view in
view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!)
}
CachedAsyncImage(url: imageURL)
.aspectRatio(contentMode: .fill)
.frame(height: 140)
.clipShape(RoundedRectangle(cornerRadius: 8))
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
ZStack {
@ -77,15 +234,12 @@ struct BookmarkCardView: View {
VStack(alignment: .leading, spacing: 4) {
HStack {
// Published date
if let publishedDate = formattedPublishedDate {
HStack {
Label(publishedDate, systemImage: "calendar")
Spacer()
}
Spacer() // show spacer only if we have the published Date
Spacer()
}
if let readingTime = bookmark.readingTime, readingTime > 0 {
@ -107,41 +261,93 @@ struct BookmarkCardView: View {
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) {
onDelete(bookmark)
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
// Archive (left)
Button {
onArchive(bookmark)
} label: {
if currentState == .archived {
Label("Restore", systemImage: "tray.and.arrow.up")
} else {
Label("Archive", systemImage: "archivebox")
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
}
private var naturalLayoutView: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottomTrailing) {
CachedAsyncImage(url: imageURL)
.aspectRatio(contentMode: .fit)
.frame(minHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 8))
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
ZStack {
Circle()
.fill(Color(.systemBackground))
.frame(width: 36, height: 36)
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
.frame(width: 32, height: 32)
Circle()
.trim(from: 0, to: CGFloat(bookmark.readProgress) / 100)
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 32, height: 32)
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text("\(bookmark.readProgress)")
.font(.caption2)
.bold()
Text("%")
.font(.system(size: 8))
.baselineOffset(2)
}
}
.padding(8)
}
}
.tint(currentState == .archived ? .blue : .orange)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Remove" : "Favorite",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
HStack {
if let publishedDate = formattedPublishedDate {
HStack {
Label(publishedDate, systemImage: "calendar")
Spacer()
}
Spacer()
}
if let readingTime = bookmark.readingTime, readingTime > 0 {
Label("\(readingTime) min", systemImage: "clock")
}
}
HStack {
if !bookmark.siteName.isEmpty {
Label(bookmark.siteName, systemImage: "globe")
}
}
HStack {
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
.onTapGesture {
SafariUtil.openInSafari(url: bookmark.url)
}
}
}
.font(.caption)
.foregroundColor(.secondary)
}
.tint(bookmark.isMarked ? .gray : .pink)
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
}
// MARK: - Computed Properties
@ -156,13 +362,10 @@ struct BookmarkCardView: View {
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
guard let date = formatter.date(from: published) else {
// Fallback without milliseconds
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
guard let fallbackDate = formatter.date(from: published) else {
return nil
}
@ -173,18 +376,19 @@ struct BookmarkCardView: View {
}
private func formatDate(_ date: Date) -> String {
let now = Date()
let calendar = Calendar.current
let now = Date()
// Today
if calendar.isDateInToday(date) {
if calendar.isDate(date, inSameDayAs: now) {
let formatter = DateFormatter()
formatter.timeStyle = .short
return "Today, \(formatter.string(from: date))"
}
// Yesterday
if calendar.isDateInYesterday(date) {
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
calendar.isDate(date, inSameDayAs: yesterday) {
let formatter = DateFormatter()
formatter.timeStyle = .short
return "Yesterday, \(formatter.string(from: date))"
@ -211,13 +415,8 @@ struct BookmarkCardView: View {
}
private var imageURL: URL? {
// Prioritize image, then thumbnail, then icon
if let imageUrl = bookmark.resources.image?.src {
return URL(string: imageUrl)
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
return URL(string: thumbnailUrl)
} else if let iconUrl = bookmark.resources.icon?.src {
return URL(string: iconUrl)
}
return nil
}
@ -229,11 +428,9 @@ struct IconBadge: View {
var body: some View {
Image(systemName: systemName)
.font(.caption2)
.padding(6)
.background(color.opacity(0.2))
.foregroundColor(color)
.frame(width: 20, height: 20)
.background(color)
.foregroundColor(.white)
.clipShape(Circle())
}
}
}

View File

@ -4,8 +4,6 @@ import SwiftUI
struct BookmarksView: View {
@Namespace private var namespace
// MARK: States
@State private var viewModel: BookmarksViewModel
@ -14,7 +12,6 @@ struct BookmarksView: View {
@State private var showingAddBookmarkFromShare = false
@State private var shareURL = ""
@State private var shareTitle = ""
@State private var bookmarkToDelete: Bookmark? = nil
let state: BookmarkState
let type: [BookmarkType]
@ -39,14 +36,16 @@ struct BookmarksView: View {
var body: some View {
ZStack {
if shouldShowCenteredState {
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
skeletonLoadingView
} else if shouldShowCenteredState {
centeredStateView
} else {
bookmarksList
}
// FAB Button - only show for "Unread" and when not in error/loading state
if (state == .unread || state == .all) && !shouldShowCenteredState {
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
fabButton
}
}
@ -56,8 +55,7 @@ struct BookmarksView: View {
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
BookmarkDetailView(bookmarkId: bookmarkId)
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
@ -68,18 +66,6 @@ struct BookmarksView: View {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
)
.alert(item: $bookmarkToDelete) { bookmark in
Alert(
title: Text("Delete Bookmark"),
message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."),
primaryButton: .destructive(Text("Delete")) {
Task {
await viewModel.deleteBookmark(bookmark: bookmark)
}
},
secondaryButton: .cancel()
)
}
.onAppear {
Task {
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
@ -179,6 +165,11 @@ struct BookmarksView: View {
List {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
Button(action: {
// Don't navigate to detail if bookmark is pending deletion
if viewModel.pendingDeletes[bookmark.id] != nil {
return
}
if UIDevice.isPhone {
selectedBookmarkId = bookmark.id
} else {
@ -195,20 +186,24 @@ struct BookmarksView: View {
BookmarkCardView(
bookmark: bookmark,
currentState: state,
layout: viewModel.cardLayoutStyle,
pendingDelete: viewModel.pendingDeletes[bookmark.id],
onArchive: { bookmark in
Task {
await viewModel.toggleArchive(bookmark: bookmark)
}
},
onDelete: { bookmark in
bookmarkToDelete = bookmark
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
},
namespace: namespace
onUndoDelete: { bookmarkId in
viewModel.undoDelete(bookmarkId: bookmarkId)
}
)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
@ -219,10 +214,14 @@ struct BookmarksView: View {
}
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowInsets(EdgeInsets(
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
leading: 16,
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
trailing: 16
))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
.matchedTransitionSource(id: bookmark.id, in: namespace)
}
// Show loading indicator for pagination
@ -256,6 +255,25 @@ struct BookmarksView: View {
}
}
@ViewBuilder
private var skeletonLoadingView: some View {
ScrollView {
SkeletonLoadingView(layout: viewModel.cardLayoutStyle)
.padding(
EdgeInsets(
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
leading: 16,
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
trailing: 16
)
)
}
.background(Color(R.color.bookmark_list_bg))
.refreshable {
await viewModel.refreshBookmarks()
}
}
@ViewBuilder
private var fabButton: some View {
VStack {

View File

@ -7,21 +7,27 @@ class BookmarksViewModel {
private let getBooksmarksUseCase: PGetBookmarksUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
var bookmarks: BookmarksPage?
var isLoading = false
var isInitialLoading = true
var errorMessage: String?
var currentState: BookmarkState = .unread
var currentType = [BookmarkType.article]
var currentTag: String? = nil
var cardLayoutStyle: CardLayoutStyle = .magazine
var showingAddBookmarkFromShare = false
var shareURL = ""
var shareTitle = ""
// Undo delete functionality
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
private var cancellables = Set<AnyCancellable>()
private var limit = 20
private var limit = 50
private var offset = 0
private var hasMoreData = true
private var searchWorkItem: DispatchWorkItem?
@ -36,13 +42,31 @@ class BookmarksViewModel {
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
setupNotificationObserver()
Task {
await loadCardLayout()
}
}
private func setupNotificationObserver() {
// Listen for card layout changes
NotificationCenter.default
.publisher(for: NSNotification.Name("AddBookmarkFromShare"))
.publisher(for: .cardLayoutChanged)
.sink { notification in
if let layout = notification.object as? CardLayoutStyle {
Task { @MainActor in
self.cardLayoutStyle = layout
}
}
}
.store(in: &cancellables)
// Listen for
NotificationCenter.default
.publisher(for: .addBookmarkFromShare)
.sink { [weak self] notification in
self?.handleShareNotification(notification)
}
@ -105,6 +129,7 @@ class BookmarksViewModel {
}
isLoading = false
isInitialLoading = false
}
@MainActor
@ -168,14 +193,97 @@ class BookmarksViewModel {
}
@MainActor
func deleteBookmark(bookmark: Bookmark) async {
func deleteBookmarkWithUndo(bookmark: Bookmark) {
// Don't remove from UI immediately - just mark as pending
let pendingDelete = PendingDelete(bookmark: bookmark)
pendingDeletes[bookmark.id] = pendingDelete
// Start countdown timer for this specific delete
startDeleteCountdown(for: bookmark.id)
// Schedule actual delete after 3 seconds
let deleteTask = Task {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
// Check if not cancelled and still pending
if !Task.isCancelled, pendingDeletes[bookmark.id] != nil {
await executeDelete(bookmark: bookmark)
await MainActor.run {
// Clean up
pendingDeletes[bookmark.id]?.timer?.invalidate()
pendingDeletes.removeValue(forKey: bookmark.id)
}
}
}
// Store the task in the pending delete
pendingDeletes[bookmark.id]?.deleteTask = deleteTask
}
@MainActor
func undoDelete(bookmarkId: String) {
guard let pendingDelete = pendingDeletes[bookmarkId] else { return }
// Cancel the delete task and timer
pendingDelete.deleteTask?.cancel()
pendingDelete.timer?.invalidate()
// Remove from pending deletes
pendingDeletes.removeValue(forKey: bookmarkId)
}
private func startDeleteCountdown(for bookmarkId: String) {
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
DispatchQueue.main.async {
guard let self = self,
let pendingDelete = self.pendingDeletes[bookmarkId] else {
timer.invalidate()
return
}
pendingDelete.progress += 1.0 / 30.0 // 3 seconds / 0.1 interval = 30 steps
// Trigger UI update by modifying the dictionary
self.pendingDeletes[bookmarkId] = pendingDelete
if pendingDelete.progress >= 1.0 {
timer.invalidate()
}
}
}
pendingDeletes[bookmarkId]?.timer = timer
}
private func executeDelete(bookmark: Bookmark) async {
do {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
} catch {
errorMessage = "Error deleting bookmark"
await loadBookmarks(state: currentState)
// If delete fails, restore the bookmark
await MainActor.run {
errorMessage = "Error deleting bookmark"
if var currentBookmarks = bookmarks?.bookmarks {
currentBookmarks.insert(bookmark, at: 0)
bookmarks?.bookmarks = currentBookmarks
}
}
}
}
@MainActor
private func loadCardLayout() async {
cardLayoutStyle = await loadCardLayoutUseCase.execute()
}
}
class PendingDelete: Identifiable {
let id = UUID()
let bookmark: Bookmark
var progress: Double = 0.0
var timer: Timer?
var deleteTask: Task<Void, Never>?
init(bookmark: Bookmark) {
self.bookmark = bookmark
}
}

View File

@ -0,0 +1,26 @@
import SwiftUI
import Kingfisher
struct CachedAsyncImage: View {
let url: URL?
init(url: URL?) {
self.url = url
}
var body: some View {
if let url {
KFImage(url)
.placeholder {
Color.gray.opacity(0.3)
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
} else {
Image("placeholder")
.resizable()
.scaledToFill()
}
}
}

View File

@ -12,7 +12,5 @@
import Foundation
struct Constants {
struct Labels {
static let pageSize = 12
}
// Empty for now - can be used for other constants in the future
}

View File

@ -0,0 +1,176 @@
import SwiftUI
struct SkeletonLoadingView: View {
let layout: CardLayoutStyle
@State private var animateGradient = false
var body: some View {
LazyVStack(spacing: layout == .compact ? 8 : 12) {
ForEach(0..<6, id: \.self) { _ in
skeletonCard
}
}
.onAppear {
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
animateGradient = true
}
}
}
@ViewBuilder
private var skeletonCard: some View {
switch layout {
case .compact:
compactSkeletonCard
case .magazine:
magazineSkeletonCard
case .natural:
naturalSkeletonCard
}
}
private var compactSkeletonCard: some View {
HStack(alignment: .top, spacing: 12) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(width: 80, height: 80)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 180, height: 16)
// Description placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 120, height: 14)
Spacer()
// Bottom info placeholder
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 80, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 50, height: 12)
}
}
}
.padding(12)
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var magazineSkeletonCard: some View {
VStack(alignment: .leading, spacing: 8) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(height: 140)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 16)
// Info placeholders
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 80, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 60, height: 12)
}
.padding(.top, 4)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
}
private var naturalSkeletonCard: some View {
VStack(alignment: .leading, spacing: 8) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(minHeight: 180)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 220, height: 16)
// Info placeholders
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 90, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 70, height: 12)
}
.padding(.top, 4)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.3),
Color.gray.opacity(0.1),
Color.gray.opacity(0.3)
],
startPoint: animateGradient ? .topLeading : .topTrailing,
endPoint: animateGradient ? .bottomTrailing : .bottomLeading
)
}
}
#Preview {
ScrollView {
SkeletonLoadingView(layout: .magazine)
.padding()
}
}

View File

@ -1,5 +1,61 @@
import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = FlowResult(
in: proposal.replacingUnspecifiedDimensions().width,
subviews: subviews,
spacing: spacing
)
return result.bounds
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = FlowResult(
in: bounds.width,
subviews: subviews,
spacing: spacing
)
for (index, subview) in subviews.enumerated() {
subview.place(at: CGPoint(
x: bounds.minX + result.frames[index].minX,
y: bounds.minY + result.frames[index].minY
), proposal: ProposedViewSize(result.frames[index].size))
}
}
}
struct FlowResult {
var frames: [CGRect] = []
var bounds: CGSize = .zero
init(in maxWidth: CGFloat, subviews: LayoutSubviews, spacing: CGFloat) {
var x: CGFloat = 0
var y: CGFloat = 0
var lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += lineHeight + spacing
lineHeight = 0
}
frames.append(CGRect(x: x, y: y, width: size.width, height: size.height))
lineHeight = max(lineHeight, size.height)
x += size.width + spacing
bounds.width = max(bounds.width, x - spacing)
}
bounds.height = y + lineHeight
}
}
enum AddBookmarkFieldFocus {
case url
case labels
@ -27,7 +83,6 @@ struct TagManagementView: View {
let selectedLabelsSet: Set<String>
let searchText: Binding<String>
let isLabelsLoading: Bool
let availableLabelPages: [[BookmarkLabel]]
let filteredLabels: [BookmarkLabel]
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
@ -44,7 +99,6 @@ struct TagManagementView: View {
selectedLabels: Set<String>,
searchText: Binding<String>,
isLabelsLoading: Bool,
availableLabelPages: [[BookmarkLabel]],
filteredLabels: [BookmarkLabel],
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
onAddCustomTag: @escaping () -> Void,
@ -55,7 +109,6 @@ struct TagManagementView: View {
self.selectedLabelsSet = selectedLabels
self.searchText = searchText
self.isLabelsLoading = isLabelsLoading
self.availableLabelPages = availableLabelPages
self.filteredLabels = filteredLabels
self.searchFieldFocus = searchFieldFocus
self.onAddCustomTag = onAddCustomTag
@ -138,7 +191,7 @@ struct TagManagementView: View {
.scaleEffect(0.8)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
} else if availableLabelPages.isEmpty {
} else if allLabels.isEmpty {
VStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
@ -150,7 +203,7 @@ struct TagManagementView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
labelsTabView
labelsScrollView
}
}
.padding(.top, 8)
@ -158,28 +211,47 @@ struct TagManagementView: View {
}
@ViewBuilder
private var labelsTabView: some View {
TabView {
ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(labelsPage, id: \.id) { label in
UnifiedLabelChip(
label: label.name,
isSelected: selectedLabelsSet.contains(label.name),
isRemovable: false,
onTap: {
onToggleLabel(label.name)
}
)
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 8) {
ForEach(chunkedLabels, id: \.self) { rowLabels in
HStack(alignment: .top, spacing: 8) {
ForEach(rowLabels, id: \.id) { label in
UnifiedLabelChip(
label: label.name,
isSelected: false,
isRemovable: false,
onTap: {
onToggleLabel(label.name)
}
)
}
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal)
}
.padding(.horizontal)
}
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
.frame(height: 180)
.padding(.top, 10)
.frame(height: calculateMaxHeight())
}
private var chunkedLabels: [[BookmarkLabel]] {
let maxRows = 3
let labelsPerRow = max(1, availableUnselectedLabels.count / maxRows + (availableUnselectedLabels.count % maxRows > 0 ? 1 : 0))
return availableUnselectedLabels.chunked(into: labelsPerRow)
}
private var availableUnselectedLabels: [BookmarkLabel] {
let labelsToShow = searchText.wrappedValue.isEmpty ? allLabels : filteredLabels
return labelsToShow.filter { !selectedLabelsSet.contains($0.name) }
}
private func calculateMaxHeight() -> CGFloat {
// Berechne Höhe für maximal 3 Reihen
let rowHeight: CGFloat = 32 // Höhe eines Labels
let spacing: CGFloat = 8
let maxRows: CGFloat = 3
return (rowHeight * maxRows) + (spacing * (maxRows - 1))
}
@ViewBuilder
@ -190,11 +262,11 @@ struct TagManagementView: View {
.font(.subheadline)
.fontWeight(.medium)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
FlowLayout(spacing: 8) {
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: false,
isSelected: true,
isRemovable: true,
onTap: {
// No action for selected labels
@ -210,3 +282,11 @@ struct TagManagementView: View {
}
}
}
extension Array {
func chunked(into size: Int) -> [[Element]] {
return stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}
}

View File

@ -0,0 +1,68 @@
import SwiftUI
struct UndoToastView: View {
let bookmarkTitle: String
let progress: Double
let onUndo: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "trash")
.foregroundColor(.white)
.font(.system(size: 16, weight: .medium))
VStack(alignment: .leading, spacing: 4) {
Text("Bookmark deleted")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.white)
Text(bookmarkTitle)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
Button("Undo") {
onUndo()
}
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.white.opacity(0.2))
.clipShape(Capsule())
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.black.opacity(0.85))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
// Progress bar at bottom
VStack {
Spacer()
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle(tint: .white.opacity(0.8)))
.scaleEffect(y: 0.5)
}
)
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
}
}
#Preview {
VStack {
Spacer()
UndoToastView(
bookmarkTitle: "How to Build Great Products",
progress: 0.6,
onUndo: {}
)
.padding()
}
.background(Color.gray.opacity(0.3))
}

View File

@ -18,6 +18,8 @@ protocol UseCaseFactory {
func makeGetLabelsUseCase() -> PGetLabelsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
}
@ -102,4 +104,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
return OfflineBookmarkSyncUseCase()
}
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
return LoadCardLayoutUseCase(settingsRepository: settingsRepository)
}
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
}
}

View File

@ -77,6 +77,13 @@ class MockUseCaseFactory: UseCaseFactory {
MockAddTextToSpeechQueueUseCase()
}
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
MockLoadCardLayoutUseCase()
}
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
MockSaveCardLayoutUseCase()
}
}
@ -204,6 +211,18 @@ class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
}
}
class MockLoadCardLayoutUseCase: PLoadCardLayoutUseCase {
func execute() async -> CardLayoutStyle {
return .magazine
}
}
class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
func execute(layout: CardLayoutStyle) async {
// Mock implementation - do nothing
}
}
extension Bookmark {
static let mock: Bookmark = .init(
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)

View File

@ -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 {

View File

@ -61,7 +61,7 @@ struct SearchBookmarksView: View {
}
}
}) {
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }, namespace: namespace)
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in })
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
@ -91,8 +91,7 @@ struct SearchBookmarksView: View {
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
BookmarkDetailView(bookmarkId: bookmarkId)
}
.onAppear {
if isFirstAppearance {

View File

@ -0,0 +1,221 @@
import SwiftUI
struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Appearance", icon: "paintbrush")
.padding(.bottom, 4)
// Theme Section
VStack(alignment: .leading, spacing: 12) {
Text("Theme")
.font(.headline)
Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme)
}
}
.pickerStyle(.segmented)
.onChange(of: selectedTheme) {
saveThemeSettings()
}
}
Divider()
// Card Layout Section
VStack(alignment: .leading, spacing: 12) {
Text("Card Layout")
.font(.headline)
VStack(spacing: 16) {
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
CardLayoutPreview(
layout: layout,
isSelected: selectedCardLayout == layout
) {
selectedCardLayout = layout
saveCardLayoutSettings()
}
}
}
}
}
.onAppear {
loadSettings()
}
}
private func loadSettings() {
// Load theme setting
let themeString = UserDefaults.standard.string(forKey: "selectedTheme") ?? "system"
selectedTheme = Theme(rawValue: themeString) ?? .system
// Load card layout setting
Task {
selectedCardLayout = await loadCardLayoutUseCase.execute()
}
}
private func saveThemeSettings() {
UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme")
}
private func saveCardLayoutSettings() {
Task {
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
// Notify other parts of the app about the change
await MainActor.run {
NotificationCenter.default.post(name: .cardLayoutChanged, object: selectedCardLayout)
}
}
}
}
struct CardLayoutPreview: View {
let layout: CardLayoutStyle
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 12) {
// Visual Preview
switch layout {
case .compact:
// Compact: Small image on left, content on right
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue.opacity(0.6))
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 6)
.frame(maxWidth: .infinity)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 60)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.4))
.frame(height: 4)
.frame(maxWidth: 40)
}
}
.padding(8)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 50)
case .magazine:
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 40)
Text("Fixed 140px")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 65)
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
case .natural:
VStack(spacing: 3) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 38)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 35)
Text("Original ratio")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 75) // Höher als Magazine
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
}
VStack(alignment: .leading, spacing: 4) {
Text(layout.displayName)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(layout.description)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
#Preview {
AppearanceSettingsView()
.cardStyle()
.padding()
}

View File

@ -0,0 +1,148 @@
import SwiftUI
import Kingfisher
struct CacheSettingsView: View {
@State private var cacheSize: String = "0 MB"
@State private var maxCacheSize: Double = 200
@State private var isClearing: Bool = false
@State private var showClearAlert: Bool = false
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Cache Settings", icon: "internaldrive")
.padding(.bottom, 4)
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size")
.foregroundColor(.primary)
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Refresh") {
updateCacheSize()
}
.font(.caption)
.foregroundColor(.blue)
}
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Max Cache Size")
.foregroundColor(.primary)
Spacer()
Text("\(Int(maxCacheSize)) MB")
.font(.caption)
.foregroundColor(.secondary)
}
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
Text("Max Cache Size")
}
.onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue)
}
.accentColor(.blue)
}
Divider()
Button(action: {
showClearAlert = true
}) {
HStack {
if isClearing {
ProgressView()
.scaleEffect(0.8)
.frame(width: 24)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
.frame(width: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text("Clear Cache")
.foregroundColor(isClearing ? .secondary : .red)
Text("Remove all cached images")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
.disabled(isClearing)
}
}
.onAppear {
updateCacheSize()
loadMaxCacheSize()
}
.alert("Clear Cache", isPresented: $showClearAlert) {
Button("Cancel", role: .cancel) { }
Button("Clear", role: .destructive) {
clearCache()
}
} message: {
Text("This will remove all cached images. They will be downloaded again when needed.")
}
}
private func updateCacheSize() {
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
DispatchQueue.main.async {
switch result {
case .success(let size):
let mbSize = Double(size) / (1024 * 1024)
self.cacheSize = String(format: "%.1f MB", mbSize)
case .failure:
self.cacheSize = "Unknown"
}
}
}
}
private func loadMaxCacheSize() {
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
if let savedSize = savedSize {
maxCacheSize = Double(savedSize) / (1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize
} else {
maxCacheSize = 200
let defaultBytes = UInt(200 * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
}
}
private func updateMaxCacheSize(_ newSize: Double) {
let bytes = UInt(newSize * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
}
private func clearCache() {
isClearing = true
KingfisherManager.shared.cache.clearDiskCache {
DispatchQueue.main.async {
self.isClearing = false
self.updateCacheSize()
}
}
KingfisherManager.shared.cache.clearMemoryCache()
}
}
#Preview {
CacheSettingsView()
.cardStyle()
.padding()
}

View File

@ -15,17 +15,9 @@ struct FontSettingsView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 24) {
// Header
HStack(spacing: 8) {
Image(systemName: "textformat")
.font(.title2)
.foregroundColor(.accentColor)
Text("Font")
.font(.title2)
.fontWeight(.bold)
}
VStack(spacing: 20) {
SectionHeader(title: "Font Settings", icon: "textformat")
.padding(.bottom, 4)
// Font Family Picker
HStack(alignment: .firstTextBaseline, spacing: 16) {

View File

@ -21,6 +21,12 @@ struct SettingsContainerView: View {
FontSettingsView()
.cardStyle()
AppearanceSettingsView()
.cardStyle()
CacheSettingsView()
.cardStyle()
SettingsGeneralView()
.cardStyle()
@ -103,9 +109,19 @@ struct SettingsContainerView: View {
HStack(spacing: 8) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
Text("Developer: Ilyas Hallak")
HStack(spacing: 4) {
Text("Developer:")
.font(.footnote)
.foregroundColor(.secondary)
Button("Ilyas Hallak") {
if let url = URL(string: "https://ilyashallak.de") {
UIApplication.shared.open(url)
}
}
.font(.footnote)
.foregroundColor(.secondary)
.foregroundColor(.blue)
.underline()
}
}
HStack(spacing: 8) {
Image(systemName: "globe")

View File

@ -19,23 +19,6 @@ struct SettingsGeneralView: View {
SectionHeader(title: "General Settings", icon: "gear")
.padding(.bottom, 4)
// Theme
VStack(alignment: .leading, spacing: 12) {
Text("Theme")
.font(.headline)
Picker("Theme", selection: $viewModel.selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme)
}
}
.pickerStyle(.segmented)
.onChange(of: viewModel.selectedTheme) {
Task {
await viewModel.saveGeneralSettings()
}
}
}
VStack(alignment: .leading, spacing: 12) {
Text("General")
.font(.headline)
@ -78,38 +61,6 @@ struct SettingsGeneralView: View {
.toggleStyle(SwitchToggleStyle())
}
// Data Management
VStack(alignment: .leading, spacing: 12) {
Text("Data Management")
.font(.headline)
Button(role: .destructive) {
Task {
// await viewModel.clearCache()
}
} label: {
HStack {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Clear cache")
.foregroundColor(.red)
Spacer()
}
}
Button(role: .destructive) {
Task {
// await viewModel.resetSettings()
}
} label: {
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.red)
Text("Reset settings")
.foregroundColor(.red)
Spacer()
}
}
}
// Messages
if let successMessage = viewModel.successMessage {
HStack {

View File

@ -52,7 +52,7 @@ class SettingsGeneralViewModel {
successMessage = "Settings saved"
// send notification to apply settings to the app
NotificationCenter.default.post(name: NSNotification.Name("SettingsChanged"), object: nil)
NotificationCenter.default.post(name: .settingsChanged, object: nil)
} catch {
errorMessage = "Error saving settings"
}

View File

@ -141,16 +141,18 @@ struct SettingsServerView: View {
Button(action: {
showingLogoutAlert = true
}) {
HStack {
HStack(spacing: 6) {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.caption)
Text("Logout")
.fontWeight(.semibold)
.font(.caption)
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(8)
}
}
}

View File

@ -67,7 +67,7 @@ class SettingsServerViewModel {
isLoggedIn = true
successMessage = "Server settings saved and successfully logged in."
try await SettingsRepository().saveHasFinishedSetup(true)
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
} catch {
errorMessage = "Connection or login failed: \(error.localizedDescription)"
isLoggedIn = false
@ -80,7 +80,7 @@ class SettingsServerViewModel {
try await logoutUseCase.execute()
isLoggedIn = false
successMessage = "Logged out"
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
} catch {
errorMessage = "Error logging out"
}

View File

@ -0,0 +1,20 @@
import Foundation
extension Notification.Name {
// MARK: - App Lifecycle
static let settingsChanged = Notification.Name("SettingsChanged")
static let setupStatusChanged = Notification.Name("SetupStatusChanged")
// MARK: - Authentication
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
// MARK: - Network
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
// MARK: - UI Interactions
static let dismissKeyboard = Notification.Name("DismissKeyboard")
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
// MARK: - User Preferences
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
}

View File

@ -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()
await loadAppSettings()
}
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
Task {
await loadSetupStatus()
}
}
.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

View File

@ -51,6 +51,7 @@
<attribute name="src" optional="YES" attributeType="String"/>
</entity>
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
<attribute name="cardLayoutStyle" optional="YES" attributeType="String"/>
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fontFamily" optional="YES" attributeType="String"/>
<attribute name="fontSize" optional="YES" attributeType="String"/>