Compare commits

..

No commits in common. "5b520995acf5458ff2f3035562b51957b6699f28" and "953ff5da8d570a2a64a0c184304ce88a739ff7f2" have entirely different histories.

48 changed files with 531 additions and 1830 deletions

3
.gitignore vendored
View File

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

View File

@ -48,6 +48,9 @@
}, },
"%lld min" : { "%lld min" : {
},
"%lld minutes" : {
}, },
"%lld." : { "%lld." : {
@ -99,6 +102,12 @@
}, },
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : { "Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
},
"Automatic sync" : {
},
"Automatically mark articles as read" : {
}, },
"Available tags" : { "Available tags" : {
@ -111,6 +120,9 @@
}, },
"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)." : {
},
"Clear cache" : {
}, },
"Close" : { "Close" : {
@ -120,6 +132,9 @@
}, },
"Critical" : { "Critical" : {
},
"Data Management" : {
}, },
"Debug" : { "Debug" : {
@ -258,6 +273,9 @@
}, },
"OK" : { "OK" : {
},
"Open external links in in-app Safari" : {
}, },
"Optional: Custom title" : { "Optional: Custom title" : {
@ -301,12 +319,18 @@
} }
} }
} }
},
"Reading Settings" : {
}, },
"Remove" : { "Remove" : {
}, },
"Reset" : { "Reset" : {
},
"Reset settings" : {
}, },
"Reset to Defaults" : { "Reset to Defaults" : {
@ -316,6 +340,9 @@
}, },
"Resume listening" : { "Resume listening" : {
},
"Safari Reader Mode" : {
}, },
"Save bookmark" : { "Save bookmark" : {
@ -364,6 +391,12 @@
}, },
"Speed" : { "Speed" : {
},
"Sync interval" : {
},
"Sync Settings" : {
}, },
"Syncing with server..." : { "Syncing with server..." : {

View File

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

View File

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

View File

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

View File

@ -41,7 +41,7 @@ class SimpleAPI {
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
if httpResponse.statusCode == 401 { if httpResponse.statusCode == 401 {
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil) NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
} }
} }
let msg = String(data: data, encoding: .utf8) ?? "Unknown error" let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
@ -94,7 +94,7 @@ class SimpleAPI {
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
if httpResponse.statusCode == 401 { if httpResponse.statusCode == 401 {
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil) NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
} }
} }
let msg = String(data: data, encoding: .utf8) ?? "Unknown error" let msg = String(data: data, encoding: .utf8) ?? "Unknown error"

View File

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@ class API: PAPI {
private func handleUnauthorizedResponse(_ statusCode: Int) { private func handleUnauthorizedResponse(_ statusCode: Int) {
if statusCode == 401 { if statusCode == 401 {
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil) NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
} }
} }
} }

View File

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

View File

@ -12,7 +12,6 @@ struct Settings {
var hasFinishedSetup: Bool = false var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil var enableTTS: Bool? = nil
var theme: Theme? = nil var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var isLoggedIn: Bool { var isLoggedIn: Bool {
token != nil && !token!.isEmpty token != nil && !token!.isEmpty
@ -32,8 +31,6 @@ protocol PSettingsRepository {
func savePassword(_ password: String) async throws func savePassword(_ password: String) async throws
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
func saveServerSettings(endpoint: String, username: String, password: String, token: String) 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 } var hasFinishedSetup: Bool { get }
} }
@ -82,10 +79,6 @@ class SettingsRepository: PSettingsRepository {
existingSettings.theme = theme.rawValue existingSettings.theme = theme.rawValue
} }
if let cardLayoutStyle = settings.cardLayoutStyle {
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
}
try context.save() try context.save()
continuation.resume() continuation.resume()
} catch { } catch {
@ -122,8 +115,7 @@ class SettingsRepository: PSettingsRepository {
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue), fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue), fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
enableTTS: settingEntity?.enableTTS, 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) continuation.resume(returning: settings)
} catch { } catch {
@ -168,7 +160,7 @@ class SettingsRepository: PSettingsRepository {
self.hasFinishedSetup = true self.hasFinishedSetup = true
// Notification senden, dass sich der Setup-Status geändert hat // Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .setupStatusChanged, object: nil) NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
} }
} }
} }
@ -182,7 +174,7 @@ class SettingsRepository: PSettingsRepository {
if !token.isEmpty { if !token.isEmpty {
self.hasFinishedSetup = true self.hasFinishedSetup = true
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .setupStatusChanged, object: nil) NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
} }
} }
} }
@ -200,7 +192,7 @@ class SettingsRepository: PSettingsRepository {
self.hasFinishedSetup = hasFinishedSetup self.hasFinishedSetup = hasFinishedSetup
// Notification senden, dass sich der Setup-Status geändert hat // Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .setupStatusChanged, object: nil) NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
} }
continuation.resume() continuation.resume()
} }
@ -214,45 +206,4 @@ class SettingsRepository: PSettingsRepository {
userDefault.set(newValue, forKey: "hasFinishedSetup") 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 // Notify when server becomes available
if !wasReachable && serverReachable { if !wasReachable && serverReachable {
NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil) NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil)
} }
} }
} }

View File

@ -1,29 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,127 +0,0 @@
/*
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

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

View File

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

View File

@ -59,6 +59,19 @@ 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 // MARK: - Labels Management
@MainActor @MainActor

View File

@ -25,7 +25,7 @@ class AppViewModel: ObservableObject {
private func setupNotificationObservers() { private func setupNotificationObservers() {
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
forName: .unauthorizedAPIResponse, forName: NSNotification.Name("UnauthorizedAPIResponse"),
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
@ -35,7 +35,7 @@ class AppViewModel: ObservableObject {
} }
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
forName: .setupStatusChanged, forName: NSNotification.Name("SetupStatusChanged"),
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in

View File

@ -4,6 +4,7 @@ import Combine
struct BookmarkDetailView: View { struct BookmarkDetailView: View {
let bookmarkId: String let bookmarkId: String
let namespace: Namespace.ID?
// MARK: - States // MARK: - States
@ -23,10 +24,11 @@ struct BookmarkDetailView: View {
@EnvironmentObject var appSettings: AppSettings @EnvironmentObject var appSettings: AppSettings
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
private let headerHeight: CGFloat = 360 private let headerHeight: CGFloat = 320
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) { init(bookmarkId: String, namespace: Namespace.ID? = nil, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId self.bookmarkId = bookmarkId
self.namespace = namespace
self.viewModel = viewModel self.viewModel = viewModel
self.webViewHeight = webViewHeight self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings self.showingFontSettings = showingFontSettings
@ -64,7 +66,7 @@ struct BookmarkDetailView: View {
}) })
.frame(height: webViewHeight) .frame(height: webViewHeight)
.cornerRadius(14) .cornerRadius(14)
.padding(.horizontal, 4) .padding(.horizontal)
.animation(.easeInOut, value: webViewHeight) .animation(.easeInOut, value: webViewHeight)
} else if viewModel.isLoadingArticle { } else if viewModel.isLoadingArticle {
ProgressView("Loading article...") ProgressView("Loading article...")
@ -76,7 +78,7 @@ struct BookmarkDetailView: View {
}) { }) {
HStack { HStack {
Image(systemName: "safari") Image(systemName: "safari")
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page")) Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
} }
.font(.title3.bold()) .font(.title3.bold())
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -189,11 +191,40 @@ struct BookmarkDetailView: View {
GeometryReader { geo in GeometryReader { geo in
let offset = geo.frame(in: .global).minY let offset = geo.frame(in: .global).minY
ZStack(alignment: .top) { ZStack(alignment: .top) {
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
.scaledToFit() image
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0)) .frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
.clipped() .clipped()
.offset(y: (offset > 0 ? -offset : 0)) .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))
// Tap area and zoom icon // Tap area and zoom icon
VStack { VStack {

View File

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

View File

@ -10,24 +10,46 @@ class BookmarkLabelsViewModel {
var isInitialLoading = false var isInitialLoading = false
var errorMessage: String? var errorMessage: String?
var showErrorAlert = false var showErrorAlert = false
var currentLabels: [String] = [] var currentLabels: [String] = [] {
didSet {
if oldValue != currentLabels {
calculatePages()
}
}
}
var newLabelText = "" var newLabelText = ""
var searchText = "" var searchText = "" {
didSet {
if oldValue != searchText {
calculatePages()
}
}
}
var allLabels: [BookmarkLabel] = [] 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 availableLabels: [BookmarkLabel] { var availableLabels: [BookmarkLabel] {
return allLabels.filter { !currentLabels.contains($0.name) } return _availableLabels
} }
var filteredLabels: [BookmarkLabel] { var filteredLabels: [BookmarkLabel] {
if searchText.isEmpty { return _filteredLabels
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
} }
var availableLabelPages: [[BookmarkLabel]] = []
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
self.currentLabels = initialLabels self.currentLabels = initialLabels
@ -48,6 +70,8 @@ class BookmarkLabelsViewModel {
errorMessage = "failed to load labels" errorMessage = "failed to load labels"
showErrorAlert = true showErrorAlert = true
} }
calculatePages()
} }
@MainActor @MainActor
@ -119,4 +143,36 @@ class BookmarkLabelsViewModel {
func updateLabels(_ labels: [String]) { func updateLabels(_ labels: [String]) {
currentLabels = labels 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,7 +17,9 @@ struct ImageViewerView: View {
Color.black Color.black
.ignoresSafeArea() .ignoresSafeArea()
CachedAsyncImage(url: URL(string: imageUrl)) AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFit() .scaledToFit()
.scaleEffect(scale) .scaleEffect(scale)
.offset(offset) .offset(offset)
@ -91,6 +93,12 @@ struct ImageViewerView: View {
} }
} }
} }
} placeholder: {
ProgressView()
.scaleEffect(1.5)
.foregroundColor(.white)
}
}
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -104,4 +112,7 @@ struct ImageViewerView: View {
} }
} }
} }
#Preview {
ImageViewerView(imageUrl: "https://example.com/image.jpg")
} }

View File

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

View File

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

View File

@ -7,27 +7,21 @@ class BookmarksViewModel {
private let getBooksmarksUseCase: PGetBookmarksUseCase private let getBooksmarksUseCase: PGetBookmarksUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
var bookmarks: BookmarksPage? var bookmarks: BookmarksPage?
var isLoading = false var isLoading = false
var isInitialLoading = true
var errorMessage: String? var errorMessage: String?
var currentState: BookmarkState = .unread var currentState: BookmarkState = .unread
var currentType = [BookmarkType.article] var currentType = [BookmarkType.article]
var currentTag: String? = nil var currentTag: String? = nil
var cardLayoutStyle: CardLayoutStyle = .magazine
var showingAddBookmarkFromShare = false var showingAddBookmarkFromShare = false
var shareURL = "" var shareURL = ""
var shareTitle = "" var shareTitle = ""
// Undo delete functionality
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var limit = 50 private var limit = 20
private var offset = 0 private var offset = 0
private var hasMoreData = true private var hasMoreData = true
private var searchWorkItem: DispatchWorkItem? private var searchWorkItem: DispatchWorkItem?
@ -42,31 +36,13 @@ class BookmarksViewModel {
getBooksmarksUseCase = factory.makeGetBookmarksUseCase() getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase() deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
setupNotificationObserver() setupNotificationObserver()
Task {
await loadCardLayout()
}
} }
private func setupNotificationObserver() { private func setupNotificationObserver() {
// Listen for card layout changes
NotificationCenter.default NotificationCenter.default
.publisher(for: .cardLayoutChanged) .publisher(for: NSNotification.Name("AddBookmarkFromShare"))
.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 .sink { [weak self] notification in
self?.handleShareNotification(notification) self?.handleShareNotification(notification)
} }
@ -129,7 +105,6 @@ class BookmarksViewModel {
} }
isLoading = false isLoading = false
isInitialLoading = false
} }
@MainActor @MainActor
@ -193,97 +168,14 @@ class BookmarksViewModel {
} }
@MainActor @MainActor
func deleteBookmarkWithUndo(bookmark: Bookmark) { func deleteBookmark(bookmark: Bookmark) async {
// 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 { do {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id) try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
} catch { } catch {
// If delete fails, restore the bookmark
await MainActor.run {
errorMessage = "Error deleting bookmark" errorMessage = "Error deleting bookmark"
if var currentBookmarks = bookmarks?.bookmarks { await loadBookmarks(state: currentState)
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

@ -1,26 +0,0 @@
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,5 +12,7 @@
import Foundation import Foundation
struct Constants { struct Constants {
// Empty for now - can be used for other constants in the future struct Labels {
static let pageSize = 12
}
} }

View File

@ -1,176 +0,0 @@
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,61 +1,5 @@
import SwiftUI 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 { enum AddBookmarkFieldFocus {
case url case url
case labels case labels
@ -83,6 +27,7 @@ struct TagManagementView: View {
let selectedLabelsSet: Set<String> let selectedLabelsSet: Set<String>
let searchText: Binding<String> let searchText: Binding<String>
let isLabelsLoading: Bool let isLabelsLoading: Bool
let availableLabelPages: [[BookmarkLabel]]
let filteredLabels: [BookmarkLabel] let filteredLabels: [BookmarkLabel]
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
@ -99,6 +44,7 @@ struct TagManagementView: View {
selectedLabels: Set<String>, selectedLabels: Set<String>,
searchText: Binding<String>, searchText: Binding<String>,
isLabelsLoading: Bool, isLabelsLoading: Bool,
availableLabelPages: [[BookmarkLabel]],
filteredLabels: [BookmarkLabel], filteredLabels: [BookmarkLabel],
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil, searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
onAddCustomTag: @escaping () -> Void, onAddCustomTag: @escaping () -> Void,
@ -109,6 +55,7 @@ struct TagManagementView: View {
self.selectedLabelsSet = selectedLabels self.selectedLabelsSet = selectedLabels
self.searchText = searchText self.searchText = searchText
self.isLabelsLoading = isLabelsLoading self.isLabelsLoading = isLabelsLoading
self.availableLabelPages = availableLabelPages
self.filteredLabels = filteredLabels self.filteredLabels = filteredLabels
self.searchFieldFocus = searchFieldFocus self.searchFieldFocus = searchFieldFocus
self.onAddCustomTag = onAddCustomTag self.onAddCustomTag = onAddCustomTag
@ -191,7 +138,7 @@ struct TagManagementView: View {
.scaleEffect(0.8) .scaleEffect(0.8)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20) .padding(.vertical, 20)
} else if allLabels.isEmpty { } else if availableLabelPages.isEmpty {
VStack { VStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24)) .font(.system(size: 24))
@ -203,7 +150,7 @@ struct TagManagementView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 20) .padding(.vertical, 20)
} else { } else {
labelsScrollView labelsTabView
} }
} }
.padding(.top, 8) .padding(.top, 8)
@ -211,47 +158,28 @@ struct TagManagementView: View {
} }
@ViewBuilder @ViewBuilder
private var labelsScrollView: some View { private var labelsTabView: some View {
ScrollView(.horizontal, showsIndicators: false) { TabView {
VStack(alignment: .leading, spacing: 8) { ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
ForEach(chunkedLabels, id: \.self) { rowLabels in LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) { ForEach(labelsPage, id: \.id) { label in
ForEach(rowLabels, id: \.id) { label in
UnifiedLabelChip( UnifiedLabelChip(
label: label.name, label: label.name,
isSelected: false, isSelected: selectedLabelsSet.contains(label.name),
isRemovable: false, isRemovable: false,
onTap: { onTap: {
onToggleLabel(label.name) onToggleLabel(label.name)
} }
) )
} }
Spacer()
}
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal) .padding(.horizontal)
} }
.frame(height: calculateMaxHeight())
} }
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
private var chunkedLabels: [[BookmarkLabel]] { .frame(height: 180)
let maxRows = 3 .padding(.top, 10)
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 @ViewBuilder
@ -262,11 +190,11 @@ struct TagManagementView: View {
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
FlowLayout(spacing: 8) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip( UnifiedLabelChip(
label: label, label: label,
isSelected: true, isSelected: false,
isRemovable: true, isRemovable: true,
onTap: { onTap: {
// No action for selected labels // No action for selected labels
@ -282,11 +210,3 @@ 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

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

View File

@ -77,13 +77,6 @@ class MockUseCaseFactory: UseCaseFactory {
MockAddTextToSpeechQueueUseCase() MockAddTextToSpeechQueueUseCase()
} }
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
MockLoadCardLayoutUseCase()
}
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
MockSaveCardLayoutUseCase()
}
} }
@ -211,18 +204,6 @@ 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 { extension Bookmark {
static let mock: Bookmark = .init( 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) 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

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

View File

@ -1,221 +0,0 @@
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

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

View File

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

View File

@ -19,6 +19,23 @@ struct SettingsGeneralView: View {
SectionHeader(title: "General Settings", icon: "gear") SectionHeader(title: "General Settings", icon: "gear")
.padding(.bottom, 4) .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) { VStack(alignment: .leading, spacing: 12) {
Text("General") Text("General")
.font(.headline) .font(.headline)
@ -61,6 +78,38 @@ struct SettingsGeneralView: View {
.toggleStyle(SwitchToggleStyle()) .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 // Messages
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
HStack { HStack {

View File

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

View File

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

View File

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

View File

@ -1,20 +0,0 @@
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

@ -35,7 +35,7 @@ struct readeckApp: App {
await loadAppSettings() await loadAppSettings()
} }
} }
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in
Task { Task {
await loadAppSettings() await loadAppSettings()
} }

View File

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