Compare commits
6 Commits
660f271982
...
5b520995ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b520995ac | |||
| 8fb2a2a14e | |||
| df8a7b64b2 | |||
| 680a9562be | |||
| 2f55da92c0 | |||
| 953ff5da8d |
3
.gitignore
vendored
3
.gitignore
vendored
@ -63,3 +63,6 @@ fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/.env.default
|
||||
fastlane/AuthKey_JZJCQWW9N3.p8
|
||||
|
||||
# Documentation
|
||||
documentation/
|
||||
|
||||
@ -6,7 +6,7 @@ struct ShareBookmarkView: View {
|
||||
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
||||
|
||||
private func dismissKeyboard() {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
|
||||
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -140,7 +140,6 @@ struct ShareBookmarkView: View {
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: false,
|
||||
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
|
||||
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
|
||||
searchFieldFocus: $focusedField,
|
||||
onAddCustomTag: {
|
||||
|
||||
@ -16,12 +16,11 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
|
||||
private let logger = Logger.viewModel
|
||||
|
||||
// Computed properties for pagination
|
||||
var availableLabels: [BookmarkLabelDto] {
|
||||
return labels.filter { !selectedLabels.contains($0.name) }
|
||||
}
|
||||
|
||||
// Computed property for filtered labels based on search text
|
||||
// filtered labels based on search text
|
||||
var filteredLabels: [BookmarkLabelDto] {
|
||||
if searchText.isEmpty {
|
||||
return availableLabels
|
||||
|
||||
@ -34,7 +34,7 @@ class ShareViewController: UIViewController {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(dismissKeyboard),
|
||||
name: NSNotification.Name("DismissKeyboard"),
|
||||
name: .dismissKeyboard,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@ -39,6 +39,11 @@ class SimpleAPI {
|
||||
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
if httpResponse.statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||
}
|
||||
}
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||
@ -87,6 +92,11 @@ class SimpleAPI {
|
||||
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
if httpResponse.statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||
}
|
||||
}
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||
|
||||
41
documentation/401.md
Normal file
41
documentation/401.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Feature: Persistentes Logout bei 401 Unauthorized
|
||||
|
||||
## Problemstellung
|
||||
Wenn eine API-Anfrage mit einem `401 Unauthorized`-Response fehlschlägt, bedeutet dies, dass der aktuell gespeicherte Token oder die Session des Nutzers ungültig ist (z. B. durch manuelles Löschen des Tokens im Backend oder andere Ursachen).
|
||||
In diesem Zustand darf der User nicht weiter mit einer scheinbar gültigen Session in der App interagieren.
|
||||
|
||||
## Ziel
|
||||
Die App soll den Nutzer in einem solchen Fall automatisch vollständig **ausloggen** und auf den **Setup-/Login-Screen** umleiten.
|
||||
Dies muss **persistiert** sein, d. h. auch nach App-Neustart darf der Nutzer nicht eingeloggt zurückkehren, solange keine erfolgreiche neue Anmeldung durchgeführt wurde.
|
||||
|
||||
---
|
||||
|
||||
## Anforderungen
|
||||
|
||||
1. **Erkennen von ungültigem Token**
|
||||
- Jede API-Antwort mit `401 Unauthorized` löst den Logout-Prozess aus.
|
||||
- Optional: Kontext beachten (z. B. ob der Request ein Refresh-Token war).
|
||||
|
||||
2. **Logout-Mechanismus**
|
||||
- Alle gespeicherten Zugangsdaten (Access Token, Refresh Token, User-Daten im Keychain/Storage) werden gelöscht.
|
||||
- UI-State wird in den "nicht eingeloggten" Zustand zurückversetzt.
|
||||
- Persistenter "loggedOut"-State wird gesetzt (z. B. in `UserDefaults` oder einer App-State-DB).
|
||||
|
||||
3. **Persistenz**
|
||||
- Falls der Nutzer die App neu startet, startet er im Setup-/Login-Screen und nicht in einem alten Session-Kontext.
|
||||
|
||||
4. **Wiederanmeldung**
|
||||
- Sobald der Nutzer sich neu einloggt und erfolgreich ein Access Token erhält:
|
||||
- wird der persistente "loggedOut"-State zurückgesetzt
|
||||
- die App verhält sich wieder wie gewohnt im eingeloggten Zustand.
|
||||
|
||||
---
|
||||
|
||||
## Beispiel-Use Case
|
||||
- User ist eingeloggt in die App.
|
||||
- Im Backend wird manuell der Token gelöscht oder die Session invalidiert.
|
||||
- Nächster API-Call → API gibt `401 Unauthorized` zurück.
|
||||
- App erkennt ungültigen Zustand, **löscht alle Tokens und Session-Daten** und leitet den User sofort auf den **Setup-/Login-Screen** um.
|
||||
- Auch nach einem App-Neustart startet der User weiterhin im Setup-Screen.
|
||||
- Sobald der User sich erfolgreich einloggt, gelten alle API-Calls wieder normal.
|
||||
|
||||
18
documentation/tabbar.md
Normal file
18
documentation/tabbar.md
Normal file
@ -0,0 +1,18 @@
|
||||
## Feature: TabView nur in Root-Screens sichtbar, nicht in Detail-Views
|
||||
|
||||
### Beschreibung
|
||||
Die App verwendet aktuell eine `TabView` (bzw. einen Tab Controller), die global sichtbar ist.
|
||||
Der Workflow funktioniert grundsätzlich, allerdings wird die `TabView` auch dann angezeigt, wenn ein Benutzer von einem Tab in eine **Detailansicht** (z. B. Artikel-Detail, Item-Detail) navigiert.
|
||||
|
||||
Ziel ist es, dass die `TabView` **nur in den Root-Views** sichtbar ist.
|
||||
Beim Öffnen einer **Detail-Ansicht** soll die `TabView` automatisch ausgeblendet werden, damit dort alternativ eine eigene **Bottom Toolbar** angezeigt werden kann.
|
||||
|
||||
### Akzeptanzkriterien
|
||||
- `TabView` ist standardmäßig in den Haupt-Tabs sichtbar.
|
||||
- Navigiert der Nutzer in eine Detail-Ansicht (z. B. Rom Detail), wird die `TabView` ausgeblendet.
|
||||
- In den Detail-Ansichten kann ein eigener `Toolbar` oder eine Custom Bottom Bar sichtbar sein - ist aber kein teil von diesem task.
|
||||
- Navigation zurück zur Root-View blendet die `TabView` wieder ein.
|
||||
|
||||
# Technischer hinweis
|
||||
|
||||
To hide TabBar when we jumps towards next screen we just have to place NavigationView to the right place. Makesure Embed TabView inside NavigationView so creating unique Navigationview for both tabs.
|
||||
@ -9,9 +9,8 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
||||
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
||||
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -67,7 +66,6 @@
|
||||
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -93,6 +91,7 @@
|
||||
UI/Components/CustomTextFieldStyle.swift,
|
||||
UI/Components/TagManagementView.swift,
|
||||
UI/Components/UnifiedLabelChip.swift,
|
||||
UI/Utils/NotificationNames.swift,
|
||||
);
|
||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||
};
|
||||
@ -147,6 +146,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||
);
|
||||
@ -172,7 +172,6 @@
|
||||
5D45F9BF2DF858680048D5B8 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
|
||||
5D45F9CA2DF858680048D5B8 /* readeck */,
|
||||
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
|
||||
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
|
||||
@ -240,6 +239,7 @@
|
||||
packageProductDependencies = (
|
||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||
5D9D95482E623668009AF769 /* Kingfisher */,
|
||||
);
|
||||
productName = readeck;
|
||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||
@ -330,6 +330,7 @@
|
||||
packageReferences = (
|
||||
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
||||
@ -349,7 +350,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -357,7 +357,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -436,7 +435,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -469,7 +468,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -624,7 +623,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -668,7 +667,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -853,6 +852,14 @@
|
||||
minimumVersion = 1.21.0;
|
||||
};
|
||||
};
|
||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 8.5.0;
|
||||
};
|
||||
};
|
||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
|
||||
@ -869,6 +876,11 @@
|
||||
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
||||
productName = netfox;
|
||||
};
|
||||
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
productName = Kingfisher;
|
||||
};
|
||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
{
|
||||
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
|
||||
"originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/onevcat/Kingfisher.git",
|
||||
"state" : {
|
||||
"revision" : "2015fda791daa72c8058619545a593bf8c1dd59f",
|
||||
"version" : "8.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "netfox",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@ -2,16 +2,7 @@
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@ -42,6 +42,14 @@ class API: PAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleUnauthorizedResponse(_ statusCode: Int) {
|
||||
if statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeJSONRequestWithHeaders<T: Codable>(
|
||||
endpoint: String,
|
||||
method: HTTPMethod = .GET,
|
||||
@ -74,6 +82,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -114,6 +123,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -146,6 +156,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -181,6 +192,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
@ -342,6 +354,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
@ -379,6 +392,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -119,7 +119,7 @@ class OfflineSyncManager: ObservableObject {
|
||||
func startAutoSync() {
|
||||
// Monitor server connectivity and auto-sync when server becomes reachable
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("ServerDidBecomeAvailable"),
|
||||
forName: .serverDidBecomeAvailable,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
|
||||
@ -12,6 +12,7 @@ struct Settings {
|
||||
var hasFinishedSetup: Bool = false
|
||||
var enableTTS: Bool? = nil
|
||||
var theme: Theme? = nil
|
||||
var cardLayoutStyle: CardLayoutStyle? = nil
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
token != nil && !token!.isEmpty
|
||||
@ -31,6 +32,8 @@ protocol PSettingsRepository {
|
||||
func savePassword(_ password: String) async throws
|
||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
|
||||
func loadCardLayoutStyle() async throws -> CardLayoutStyle
|
||||
var hasFinishedSetup: Bool { get }
|
||||
}
|
||||
|
||||
@ -79,6 +82,10 @@ class SettingsRepository: PSettingsRepository {
|
||||
existingSettings.theme = theme.rawValue
|
||||
}
|
||||
|
||||
if let cardLayoutStyle = settings.cardLayoutStyle {
|
||||
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||
}
|
||||
|
||||
try context.save()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
@ -115,7 +122,8 @@ class SettingsRepository: PSettingsRepository {
|
||||
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
|
||||
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
||||
enableTTS: settingEntity?.enableTTS,
|
||||
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue)
|
||||
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
|
||||
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue)
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
} catch {
|
||||
@ -160,7 +168,7 @@ class SettingsRepository: PSettingsRepository {
|
||||
self.hasFinishedSetup = true
|
||||
// Notification senden, dass sich der Setup-Status geändert hat
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -174,7 +182,7 @@ class SettingsRepository: PSettingsRepository {
|
||||
if !token.isEmpty {
|
||||
self.hasFinishedSetup = true
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -192,7 +200,7 @@ class SettingsRepository: PSettingsRepository {
|
||||
self.hasFinishedSetup = hasFinishedSetup
|
||||
// Notification senden, dass sich der Setup-Status geändert hat
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
@ -206,4 +214,45 @@ class SettingsRepository: PSettingsRepository {
|
||||
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
||||
}
|
||||
}
|
||||
|
||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
|
||||
let context = coreDataManager.context
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
|
||||
|
||||
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||
|
||||
try context.save()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadCardLayoutStyle() async throws -> CardLayoutStyle {
|
||||
let context = coreDataManager.context
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let settingEntities = try context.fetch(fetchRequest)
|
||||
let settingEntity = settingEntities.first
|
||||
|
||||
let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine
|
||||
continuation.resume(returning: cardLayoutStyle)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ class ServerConnectivity: ObservableObject {
|
||||
|
||||
// Notify when server becomes available
|
||||
if !wasReachable && serverReachable {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil)
|
||||
NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
readeck/Domain/Model/CardLayoutStyle.swift
Normal file
29
readeck/Domain/Model/CardLayoutStyle.swift
Normal file
@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
enum CardLayoutStyle: String, CaseIterable, Codable {
|
||||
case compact = "compact"
|
||||
case magazine = "magazine"
|
||||
case natural = "natural"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .compact:
|
||||
return "Compact"
|
||||
case .magazine:
|
||||
return "Magazine"
|
||||
case .natural:
|
||||
return "Natural"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .compact:
|
||||
return "Small thumbnails with content focus"
|
||||
case .magazine:
|
||||
return "Fixed height headers for consistent layout"
|
||||
case .natural:
|
||||
return "Images in original aspect ratio"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
readeck/Domain/UseCase/LoadCardLayoutUseCase.swift
Normal file
21
readeck/Domain/UseCase/LoadCardLayoutUseCase.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
protocol PLoadCardLayoutUseCase {
|
||||
func execute() async -> CardLayoutStyle
|
||||
}
|
||||
|
||||
class LoadCardLayoutUseCase: PLoadCardLayoutUseCase {
|
||||
private let settingsRepository: PSettingsRepository
|
||||
|
||||
init(settingsRepository: PSettingsRepository) {
|
||||
self.settingsRepository = settingsRepository
|
||||
}
|
||||
|
||||
func execute() async -> CardLayoutStyle {
|
||||
do {
|
||||
return try await settingsRepository.loadCardLayoutStyle()
|
||||
} catch {
|
||||
return .magazine
|
||||
}
|
||||
}
|
||||
}
|
||||
22
readeck/Domain/UseCase/SaveCardLayoutUseCase.swift
Normal file
22
readeck/Domain/UseCase/SaveCardLayoutUseCase.swift
Normal file
@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
protocol PSaveCardLayoutUseCase {
|
||||
func execute(layout: CardLayoutStyle) async
|
||||
}
|
||||
|
||||
class SaveCardLayoutUseCase: PSaveCardLayoutUseCase {
|
||||
private let settingsRepository: PSettingsRepository
|
||||
private let logger = Logger.data
|
||||
|
||||
init(settingsRepository: PSettingsRepository) {
|
||||
self.settingsRepository = settingsRepository
|
||||
}
|
||||
|
||||
func execute(layout: CardLayoutStyle) async {
|
||||
do {
|
||||
try await settingsRepository.saveCardLayoutStyle(layout)
|
||||
} catch {
|
||||
logger.error("Failed to save card layout style: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
127
readeck/Localizations/Base.lproj/Localizable.strings
Normal file
127
readeck/Localizations/Base.lproj/Localizable.strings
Normal file
@ -0,0 +1,127 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
readeck
|
||||
|
||||
Created by conversion from Localizable.xcstrings
|
||||
*/
|
||||
|
||||
"" = "";
|
||||
"(%lld found)" = "(%lld found)";
|
||||
"%" = "%";
|
||||
"%@ (%lld)" = "%1$@ (%2$lld)";
|
||||
"%lld" = "%lld";
|
||||
"%lld articles in the queue" = "%lld articles in the queue";
|
||||
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
|
||||
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
|
||||
"%lld min" = "%lld min";
|
||||
"%lld." = "%lld.";
|
||||
"%lld/%lld" = "%1$lld/%2$lld";
|
||||
"12 min • Today • example.com" = "12 min • Today • example.com";
|
||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
||||
"Add" = "Add";
|
||||
"Add new tag:" = "Add new tag:";
|
||||
"all" = "all";
|
||||
"All tags selected" = "All tags selected";
|
||||
"Archive" = "Archive";
|
||||
"Archive bookmark" = "Archive bookmark";
|
||||
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
|
||||
"Available tags" = "Available tags";
|
||||
"Cancel" = "Cancel";
|
||||
"Category-specific Levels" = "Category-specific Levels";
|
||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
|
||||
"Close" = "Close";
|
||||
"Configure log levels and categories" = "Configure log levels and categories";
|
||||
"Critical" = "Critical";
|
||||
"Debug" = "Debug";
|
||||
"DEBUG BUILD" = "DEBUG BUILD";
|
||||
"Debug Settings" = "Debug Settings";
|
||||
"Delete" = "Delete";
|
||||
"Delete Bookmark" = "Delete Bookmark";
|
||||
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
|
||||
"Done" = "Done";
|
||||
"Enter an optional title..." = "Enter an optional title...";
|
||||
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
|
||||
"Error" = "Error";
|
||||
"Error: %@" = "Error: %@";
|
||||
"Favorite" = "Favorite";
|
||||
"Finished reading?" = "Finished reading?";
|
||||
"Font" = "Font";
|
||||
"Font family" = "Font family";
|
||||
"Font Settings" = "Font Settings";
|
||||
"Font size" = "Font size";
|
||||
"From Bremen with 💚" = "From Bremen with 💚";
|
||||
"General" = "General";
|
||||
"Global Level" = "Global Level";
|
||||
"Global Minimum Level" = "Global Minimum Level";
|
||||
"Global Settings" = "Global Settings";
|
||||
"https://example.com" = "https://example.com";
|
||||
"https://readeck.example.com" = "https://readeck.example.com";
|
||||
"Include Source Location" = "Include Source Location";
|
||||
"Info" = "Info";
|
||||
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
|
||||
"Key" = "Key";
|
||||
"Level for %@" = "Level for %@";
|
||||
"Loading %@" = "Loading %@";
|
||||
"Loading article..." = "Loading article...";
|
||||
"Logging Configuration" = "Logging Configuration";
|
||||
"Login & Save" = "Login & Save";
|
||||
"Logout" = "Logout";
|
||||
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
|
||||
"Manage Labels" = "Manage Labels";
|
||||
"Mark as favorite" = "Mark as favorite";
|
||||
"More" = "More";
|
||||
"New Bookmark" = "New Bookmark";
|
||||
"No articles in the queue" = "No articles in the queue";
|
||||
"No bookmarks" = "No bookmarks";
|
||||
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
||||
"No bookmarks found." = "No bookmarks found.";
|
||||
"No results" = "No results";
|
||||
"Notice" = "Notice";
|
||||
"OK" = "OK";
|
||||
"Optional: Custom title" = "Optional: Custom title";
|
||||
"Password" = "Password";
|
||||
"Paste" = "Paste";
|
||||
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
|
||||
"Preview" = "Preview";
|
||||
"Progress: %lld%%" = "Progress: %lld%%";
|
||||
"Re-login & Save" = "Re-login & Save";
|
||||
"Read Aloud Feature" = "Read Aloud Feature";
|
||||
"Read article aloud" = "Read article aloud";
|
||||
"Read-aloud Queue" = "Read-aloud Queue";
|
||||
"readeck Bookmark Title" = "readeck Bookmark Title";
|
||||
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
|
||||
"Remove" = "Remove";
|
||||
"Reset" = "Reset";
|
||||
"Reset to Defaults" = "Reset to Defaults";
|
||||
"Restore" = "Restore";
|
||||
"Resume listening" = "Resume listening";
|
||||
"Save bookmark" = "Save bookmark";
|
||||
"Save Bookmark" = "Save Bookmark";
|
||||
"Saving..." = "Saving...";
|
||||
"Search" = "Search";
|
||||
"Search or add new tag..." = "Search or add new tag...";
|
||||
"Search results" = "Search results";
|
||||
"Search..." = "Search...";
|
||||
"Searching..." = "Searching...";
|
||||
"Select a bookmark or tag" = "Select a bookmark or tag";
|
||||
"Selected tags" = "Selected tags";
|
||||
"Server Endpoint" = "Server Endpoint";
|
||||
"Server not reachable - saving locally" = "Server not reachable - saving locally";
|
||||
"Settings" = "Settings";
|
||||
"Show Performance Logs" = "Show Performance Logs";
|
||||
"Show Timestamps" = "Show Timestamps";
|
||||
"Speed" = "Speed";
|
||||
"Syncing with server..." = "Syncing with server...";
|
||||
"Theme" = "Theme";
|
||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
|
||||
"Try Again" = "Try Again";
|
||||
"Unable to load bookmarks" = "Unable to load bookmarks";
|
||||
"Unarchive Bookmark" = "Unarchive Bookmark";
|
||||
"URL in clipboard:" = "URL in clipboard:";
|
||||
"Username" = "Username";
|
||||
"Version %@" = "Version %@";
|
||||
"Warning" = "Warning";
|
||||
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
|
||||
"Your Password" = "Your Password";
|
||||
"Your Username" = "Your Username";
|
||||
8
readeck/Localizations/de.lproj/Localizable.strings
Normal file
8
readeck/Localizations/de.lproj/Localizable.strings
Normal file
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Localizable.strings (German)
|
||||
readeck
|
||||
|
||||
Created by conversion from Localizable.xcstrings
|
||||
*/
|
||||
|
||||
"all" = "Ale";
|
||||
127
readeck/Localizations/en.lproj/Localizable.strings
Normal file
127
readeck/Localizations/en.lproj/Localizable.strings
Normal file
@ -0,0 +1,127 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
readeck
|
||||
|
||||
Created by conversion from Localizable.xcstrings
|
||||
*/
|
||||
|
||||
"" = "";
|
||||
"(%lld found)" = "(%lld found)";
|
||||
"%" = "%";
|
||||
"%@ (%lld)" = "%1$@ (%2$lld)";
|
||||
"%lld" = "%lld";
|
||||
"%lld articles in the queue" = "%lld articles in the queue";
|
||||
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
|
||||
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
|
||||
"%lld min" = "%lld min";
|
||||
"%lld." = "%lld.";
|
||||
"%lld/%lld" = "%1$lld/%2$lld";
|
||||
"12 min • Today • example.com" = "12 min • Today • example.com";
|
||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
||||
"Add" = "Add";
|
||||
"Add new tag:" = "Add new tag:";
|
||||
"all" = "all";
|
||||
"All tags selected" = "All tags selected";
|
||||
"Archive" = "Archive";
|
||||
"Archive bookmark" = "Archive bookmark";
|
||||
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
|
||||
"Available tags" = "Available tags";
|
||||
"Cancel" = "Cancel";
|
||||
"Category-specific Levels" = "Category-specific Levels";
|
||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
|
||||
"Close" = "Close";
|
||||
"Configure log levels and categories" = "Configure log levels and categories";
|
||||
"Critical" = "Critical";
|
||||
"Debug" = "Debug";
|
||||
"DEBUG BUILD" = "DEBUG BUILD";
|
||||
"Debug Settings" = "Debug Settings";
|
||||
"Delete" = "Delete";
|
||||
"Delete Bookmark" = "Delete Bookmark";
|
||||
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
|
||||
"Done" = "Done";
|
||||
"Enter an optional title..." = "Enter an optional title...";
|
||||
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
|
||||
"Error" = "Error";
|
||||
"Error: %@" = "Error: %@";
|
||||
"Favorite" = "Favorite";
|
||||
"Finished reading?" = "Finished reading?";
|
||||
"Font" = "Font";
|
||||
"Font family" = "Font family";
|
||||
"Font Settings" = "Font Settings";
|
||||
"Font size" = "Font size";
|
||||
"From Bremen with 💚" = "From Bremen with 💚";
|
||||
"General" = "General";
|
||||
"Global Level" = "Global Level";
|
||||
"Global Minimum Level" = "Global Minimum Level";
|
||||
"Global Settings" = "Global Settings";
|
||||
"https://example.com" = "https://example.com";
|
||||
"https://readeck.example.com" = "https://readeck.example.com";
|
||||
"Include Source Location" = "Include Source Location";
|
||||
"Info" = "Info";
|
||||
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
|
||||
"Key" = "Key";
|
||||
"Level for %@" = "Level for %@";
|
||||
"Loading %@" = "Loading %@";
|
||||
"Loading article..." = "Loading article...";
|
||||
"Logging Configuration" = "Logging Configuration";
|
||||
"Login & Save" = "Login & Save";
|
||||
"Logout" = "Logout";
|
||||
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
|
||||
"Manage Labels" = "Manage Labels";
|
||||
"Mark as favorite" = "Mark as favorite";
|
||||
"More" = "More";
|
||||
"New Bookmark" = "New Bookmark";
|
||||
"No articles in the queue" = "No articles in the queue";
|
||||
"No bookmarks" = "No bookmarks";
|
||||
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
||||
"No bookmarks found." = "No bookmarks found.";
|
||||
"No results" = "No results";
|
||||
"Notice" = "Notice";
|
||||
"OK" = "OK";
|
||||
"Optional: Custom title" = "Optional: Custom title";
|
||||
"Password" = "Password";
|
||||
"Paste" = "Paste";
|
||||
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
|
||||
"Preview" = "Preview";
|
||||
"Progress: %lld%%" = "Progress: %lld%%";
|
||||
"Re-login & Save" = "Re-login & Save";
|
||||
"Read Aloud Feature" = "Read Aloud Feature";
|
||||
"Read article aloud" = "Read article aloud";
|
||||
"Read-aloud Queue" = "Read-aloud Queue";
|
||||
"readeck Bookmark Title" = "readeck Bookmark Title";
|
||||
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
|
||||
"Remove" = "Remove";
|
||||
"Reset" = "Reset";
|
||||
"Reset to Defaults" = "Reset to Defaults";
|
||||
"Restore" = "Restore";
|
||||
"Resume listening" = "Resume listening";
|
||||
"Save bookmark" = "Save bookmark";
|
||||
"Save Bookmark" = "Save Bookmark";
|
||||
"Saving..." = "Saving...";
|
||||
"Search" = "Search";
|
||||
"Search or add new tag..." = "Search or add new tag...";
|
||||
"Search results" = "Search results";
|
||||
"Search..." = "Search...";
|
||||
"Searching..." = "Searching...";
|
||||
"Select a bookmark or tag" = "Select a bookmark or tag";
|
||||
"Selected tags" = "Selected tags";
|
||||
"Server Endpoint" = "Server Endpoint";
|
||||
"Server not reachable - saving locally" = "Server not reachable - saving locally";
|
||||
"Settings" = "Settings";
|
||||
"Show Performance Logs" = "Show Performance Logs";
|
||||
"Show Timestamps" = "Show Timestamps";
|
||||
"Speed" = "Speed";
|
||||
"Syncing with server..." = "Syncing with server...";
|
||||
"Theme" = "Theme";
|
||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
|
||||
"Try Again" = "Try Again";
|
||||
"Unable to load bookmarks" = "Unable to load bookmarks";
|
||||
"Unarchive Bookmark" = "Unarchive Bookmark";
|
||||
"URL in clipboard:" = "URL in clipboard:";
|
||||
"Username" = "Username";
|
||||
"Version %@" = "Version %@";
|
||||
"Warning" = "Warning";
|
||||
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
|
||||
"Your Password" = "Your Password";
|
||||
"Your Username" = "Your Username";
|
||||
@ -74,11 +74,9 @@ struct AddBookmarkView: View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
urlField
|
||||
.id("urlField")
|
||||
Spacer()
|
||||
.frame(height: 40)
|
||||
.id("labelsOffset")
|
||||
labelsField
|
||||
.id("labelsField")
|
||||
@ -160,10 +158,11 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(12)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,7 +182,6 @@ struct AddBookmarkView: View {
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: viewModel.isLabelsLoading,
|
||||
availableLabelPages: viewModel.availableLabelPages,
|
||||
filteredLabels: viewModel.filteredLabels,
|
||||
searchFieldFocus: $focusedField,
|
||||
onAddCustomTag: {
|
||||
|
||||
@ -59,19 +59,6 @@ class AddBookmarkViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
var availableLabelPages: [[BookmarkLabel]] {
|
||||
let pageSize = Constants.Labels.pageSize
|
||||
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
|
||||
|
||||
if labelsToShow.count <= pageSize {
|
||||
return [labelsToShow]
|
||||
} else {
|
||||
return stride(from: 0, to: labelsToShow.count, by: pageSize).map {
|
||||
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Labels Management
|
||||
|
||||
@MainActor
|
||||
|
||||
71
readeck/UI/AppViewModel.swift
Normal file
71
readeck/UI/AppViewModel.swift
Normal file
@ -0,0 +1,71 @@
|
||||
//
|
||||
// AppViewModel.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 27.08.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class AppViewModel: ObservableObject {
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private let logoutUseCase: LogoutUseCase
|
||||
|
||||
@Published var hasFinishedSetup: Bool = true
|
||||
|
||||
init(logoutUseCase: LogoutUseCase = LogoutUseCase()) {
|
||||
self.logoutUseCase = logoutUseCase
|
||||
setupNotificationObservers()
|
||||
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupNotificationObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .unauthorizedAPIResponse,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task {
|
||||
await self?.handleUnauthorizedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .setupStatusChanged,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.loadSetupStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleUnauthorizedResponse() async {
|
||||
print("AppViewModel: Handling 401 Unauthorized - logging out user")
|
||||
|
||||
do {
|
||||
// Führe den Logout durch
|
||||
try await logoutUseCase.execute()
|
||||
|
||||
// Update UI state
|
||||
loadSetupStatus()
|
||||
|
||||
print("AppViewModel: User successfully logged out due to 401 error")
|
||||
} catch {
|
||||
print("AppViewModel: Error during logout: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadSetupStatus() {
|
||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@ import Combine
|
||||
|
||||
struct BookmarkDetailView: View {
|
||||
let bookmarkId: String
|
||||
let namespace: Namespace.ID?
|
||||
|
||||
// MARK: - States
|
||||
|
||||
@ -24,11 +23,10 @@ struct BookmarkDetailView: View {
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let headerHeight: CGFloat = 320
|
||||
private let headerHeight: CGFloat = 360
|
||||
|
||||
init(bookmarkId: String, namespace: Namespace.ID? = nil, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
|
||||
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
|
||||
self.bookmarkId = bookmarkId
|
||||
self.namespace = namespace
|
||||
self.viewModel = viewModel
|
||||
self.webViewHeight = webViewHeight
|
||||
self.showingFontSettings = showingFontSettings
|
||||
@ -66,7 +64,7 @@ struct BookmarkDetailView: View {
|
||||
})
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 4)
|
||||
.animation(.easeInOut, value: webViewHeight)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
@ -78,7 +76,7 @@ struct BookmarkDetailView: View {
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
@ -191,40 +189,11 @@ struct BookmarkDetailView: View {
|
||||
GeometryReader { geo in
|
||||
let offset = geo.frame(in: .global).minY
|
||||
ZStack(alignment: .top) {
|
||||
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
.scaledToFit()
|
||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||
.clipped()
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
.if(namespace != nil) { view in
|
||||
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
|
||||
}
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.4))
|
||||
.frame(width: geometry.size.width, height: headerHeight)
|
||||
.if(namespace != nil) { view in
|
||||
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
|
||||
}
|
||||
}
|
||||
// Gradient overlay für bessere Button-Sichtbarkeit
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.black.opacity(1.0),
|
||||
Color.black.opacity(0.9),
|
||||
Color.black.opacity(0.7),
|
||||
Color.black.opacity(0.4),
|
||||
Color.black.opacity(0.2),
|
||||
Color.clear
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 240)
|
||||
.frame(maxWidth: .infinity)
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
|
||||
// Tap area and zoom icon
|
||||
VStack {
|
||||
|
||||
@ -61,7 +61,6 @@ struct BookmarkLabelsView: View {
|
||||
selectedLabels: Set(viewModel.currentLabels),
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: viewModel.isInitialLoading,
|
||||
availableLabelPages: viewModel.availableLabelPages,
|
||||
filteredLabels: viewModel.filteredLabels,
|
||||
onAddCustomTag: {
|
||||
Task {
|
||||
|
||||
@ -10,45 +10,23 @@ class BookmarkLabelsViewModel {
|
||||
var isInitialLoading = false
|
||||
var errorMessage: String?
|
||||
var showErrorAlert = false
|
||||
var currentLabels: [String] = [] {
|
||||
didSet {
|
||||
if oldValue != currentLabels {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
var currentLabels: [String] = []
|
||||
var newLabelText = ""
|
||||
var searchText = "" {
|
||||
didSet {
|
||||
if oldValue != searchText {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
var searchText = ""
|
||||
|
||||
var allLabels: [BookmarkLabel] = [] {
|
||||
didSet {
|
||||
if oldValue != allLabels {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var labelPages: [[BookmarkLabel]] = []
|
||||
|
||||
// Cached properties to avoid recomputation
|
||||
private var _availableLabels: [BookmarkLabel] = []
|
||||
private var _filteredLabels: [BookmarkLabel] = []
|
||||
var allLabels: [BookmarkLabel] = []
|
||||
|
||||
var availableLabels: [BookmarkLabel] {
|
||||
return _availableLabels
|
||||
return allLabels.filter { !currentLabels.contains($0.name) }
|
||||
}
|
||||
|
||||
var filteredLabels: [BookmarkLabel] {
|
||||
return _filteredLabels
|
||||
if searchText.isEmpty {
|
||||
return availableLabels
|
||||
} else {
|
||||
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
}
|
||||
|
||||
var availableLabelPages: [[BookmarkLabel]] = []
|
||||
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
|
||||
self.currentLabels = initialLabels
|
||||
@ -70,8 +48,6 @@ class BookmarkLabelsViewModel {
|
||||
errorMessage = "failed to load labels"
|
||||
showErrorAlert = true
|
||||
}
|
||||
|
||||
calculatePages()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -143,36 +119,4 @@ class BookmarkLabelsViewModel {
|
||||
func updateLabels(_ labels: [String]) {
|
||||
currentLabels = labels
|
||||
}
|
||||
|
||||
private func calculatePages() {
|
||||
let pageSize = Constants.Labels.pageSize
|
||||
|
||||
// Update cached available labels
|
||||
_availableLabels = allLabels.filter { !currentLabels.contains($0.name) }
|
||||
|
||||
// Update cached filtered labels
|
||||
if searchText.isEmpty {
|
||||
_filteredLabels = _availableLabels
|
||||
} else {
|
||||
_filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
// Calculate pages for all labels
|
||||
if allLabels.count <= pageSize {
|
||||
labelPages = [allLabels]
|
||||
} else {
|
||||
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
|
||||
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate pages for filtered labels
|
||||
if _filteredLabels.count <= pageSize {
|
||||
availableLabelPages = [_filteredLabels]
|
||||
} else {
|
||||
availableLabelPages = stride(from: 0, to: _filteredLabels.count, by: pageSize).map {
|
||||
Array(_filteredLabels[$0..<min($0 + pageSize, _filteredLabels.count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,9 +17,7 @@ struct ImageViewerView: View {
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
|
||||
AsyncImage(url: URL(string: imageUrl)) { image in
|
||||
image
|
||||
.resizable()
|
||||
CachedAsyncImage(url: URL(string: imageUrl))
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(offset)
|
||||
@ -93,12 +91,6 @@ struct ImageViewerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
} placeholder: {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@ -111,8 +103,5 @@ struct ImageViewerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ImageViewerView(imageUrl: "https://example.com/image.jpg")
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import SafariServices
|
||||
|
||||
extension View {
|
||||
@ -12,35 +13,191 @@ extension View {
|
||||
}
|
||||
|
||||
struct BookmarkCardView: View {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
let bookmark: Bookmark
|
||||
let currentState: BookmarkState
|
||||
let layout: CardLayoutStyle
|
||||
let pendingDelete: PendingDelete?
|
||||
let onArchive: (Bookmark) -> Void
|
||||
let onDelete: (Bookmark) -> Void
|
||||
let onToggleFavorite: (Bookmark) -> Void
|
||||
let namespace: Namespace.ID?
|
||||
let onUndoDelete: ((String) -> Void)?
|
||||
|
||||
init(
|
||||
bookmark: Bookmark,
|
||||
currentState: BookmarkState,
|
||||
layout: CardLayoutStyle = .magazine,
|
||||
pendingDelete: PendingDelete? = nil,
|
||||
onArchive: @escaping (Bookmark) -> Void,
|
||||
onDelete: @escaping (Bookmark) -> Void,
|
||||
onToggleFavorite: @escaping (Bookmark) -> Void,
|
||||
onUndoDelete: ((String) -> Void)? = nil
|
||||
) {
|
||||
self.bookmark = bookmark
|
||||
self.currentState = currentState
|
||||
self.layout = layout
|
||||
self.pendingDelete = pendingDelete
|
||||
self.onArchive = onArchive
|
||||
self.onDelete = onDelete
|
||||
self.onToggleFavorite = onToggleFavorite
|
||||
self.onUndoDelete = onUndoDelete
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Group {
|
||||
switch layout {
|
||||
case .compact:
|
||||
compactLayoutView
|
||||
case .magazine:
|
||||
magazineLayoutView
|
||||
case .natural:
|
||||
naturalLayoutView
|
||||
}
|
||||
}
|
||||
.opacity(pendingDelete != nil ? 0.4 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.2), value: pendingDelete != nil)
|
||||
|
||||
// Undo toast overlay with progress background
|
||||
if let pendingDelete = pendingDelete {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Undo button area with circular progress
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
// Circular progress indicator
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 2)
|
||||
.frame(width: 16, height: 16)
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(pendingDelete.progress))
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.animation(.linear(duration: 0.1), value: pendingDelete.progress)
|
||||
}
|
||||
|
||||
Text("Deleting...")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Undo") {
|
||||
onUndoDelete?(bookmark.id)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(.blue)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
.onTapGesture {
|
||||
onUndoDelete?(bookmark.id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: layout == .compact ? 8 : 12))
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if pendingDelete == nil {
|
||||
Button("Delete", role: .destructive) {
|
||||
onDelete(bookmark)
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if pendingDelete == nil {
|
||||
Button {
|
||||
onArchive(bookmark)
|
||||
} label: {
|
||||
if currentState == .archived {
|
||||
Label("Restore", systemImage: "tray.and.arrow.up")
|
||||
} else {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
}
|
||||
.tint(currentState == .archived ? .blue : .orange)
|
||||
|
||||
Button {
|
||||
onToggleFavorite(bookmark)
|
||||
} label: {
|
||||
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
||||
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
||||
}
|
||||
.tint(bookmark.isMarked ? .gray : .pink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayoutView: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
CachedAsyncImage(url: imageURL)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.title)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if !bookmark.description.isEmpty {
|
||||
Text(bookmark.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if !bookmark.siteName.isEmpty {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "globe")
|
||||
Text(bookmark.siteName)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "clock")
|
||||
Text("\(readingTime) min")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var magazineLayoutView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AsyncImage(url: imageURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
CachedAsyncImage(url: imageURL)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
} placeholder: {
|
||||
|
||||
Image(R.image.placeholder.name)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
}
|
||||
.frame(height: 140)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.if(namespace != nil) { view in
|
||||
view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!)
|
||||
}
|
||||
|
||||
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
||||
ZStack {
|
||||
@ -77,15 +234,12 @@ struct BookmarkCardView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
|
||||
// Published date
|
||||
if let publishedDate = formattedPublishedDate {
|
||||
HStack {
|
||||
Label(publishedDate, systemImage: "calendar")
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer() // show spacer only if we have the published Date
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||
@ -107,41 +261,93 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button("Delete", role: .destructive) {
|
||||
onDelete(bookmark)
|
||||
.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)
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
// Archive (left)
|
||||
Button {
|
||||
onArchive(bookmark)
|
||||
} label: {
|
||||
if currentState == .archived {
|
||||
Label("Restore", systemImage: "tray.and.arrow.up")
|
||||
} else {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
}
|
||||
.tint(currentState == .archived ? .blue : .orange)
|
||||
|
||||
Button {
|
||||
onToggleFavorite(bookmark)
|
||||
} label: {
|
||||
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
||||
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
||||
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)
|
||||
}
|
||||
.tint(bookmark.isMarked ? .gray : .pink)
|
||||
}
|
||||
.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
|
||||
@ -156,13 +362,10 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
|
||||
|
||||
guard let date = formatter.date(from: published) else {
|
||||
// Fallback without milliseconds
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
guard let fallbackDate = formatter.date(from: published) else {
|
||||
return nil
|
||||
}
|
||||
@ -173,18 +376,19 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
// Today
|
||||
if calendar.isDateInToday(date) {
|
||||
if calendar.isDate(date, inSameDayAs: now) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return "Today, \(formatter.string(from: date))"
|
||||
}
|
||||
|
||||
// Yesterday
|
||||
if calendar.isDateInYesterday(date) {
|
||||
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
|
||||
calendar.isDate(date, inSameDayAs: yesterday) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return "Yesterday, \(formatter.string(from: date))"
|
||||
@ -211,13 +415,8 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
|
||||
private var imageURL: URL? {
|
||||
// Prioritize image, then thumbnail, then icon
|
||||
if let imageUrl = bookmark.resources.image?.src {
|
||||
return URL(string: imageUrl)
|
||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
||||
return URL(string: thumbnailUrl)
|
||||
} else if let iconUrl = bookmark.resources.icon?.src {
|
||||
return URL(string: iconUrl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -229,11 +428,9 @@ struct IconBadge: View {
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: systemName)
|
||||
.font(.caption2)
|
||||
.padding(6)
|
||||
.background(color.opacity(0.2))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 20, height: 20)
|
||||
.background(color)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,8 +4,6 @@ import SwiftUI
|
||||
|
||||
struct BookmarksView: View {
|
||||
|
||||
@Namespace private var namespace
|
||||
|
||||
// MARK: States
|
||||
|
||||
@State private var viewModel: BookmarksViewModel
|
||||
@ -14,7 +12,6 @@ struct BookmarksView: View {
|
||||
@State private var showingAddBookmarkFromShare = false
|
||||
@State private var shareURL = ""
|
||||
@State private var shareTitle = ""
|
||||
@State private var bookmarkToDelete: Bookmark? = nil
|
||||
|
||||
let state: BookmarkState
|
||||
let type: [BookmarkType]
|
||||
@ -39,14 +36,16 @@ struct BookmarksView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if shouldShowCenteredState {
|
||||
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
|
||||
skeletonLoadingView
|
||||
} else if shouldShowCenteredState {
|
||||
centeredStateView
|
||||
} else {
|
||||
bookmarksList
|
||||
}
|
||||
|
||||
// FAB Button - only show for "Unread" and when not in error/loading state
|
||||
if (state == .unread || state == .all) && !shouldShowCenteredState {
|
||||
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
|
||||
fabButton
|
||||
}
|
||||
}
|
||||
@ -56,8 +55,7 @@ struct BookmarksView: View {
|
||||
set: { selectedBookmarkId = $0 }
|
||||
)
|
||||
) { bookmarkId in
|
||||
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
||||
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
||||
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||
}
|
||||
.sheet(isPresented: $showingAddBookmark) {
|
||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||
@ -68,18 +66,6 @@ struct BookmarksView: View {
|
||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||
}
|
||||
)
|
||||
.alert(item: $bookmarkToDelete) { bookmark in
|
||||
Alert(
|
||||
title: Text("Delete Bookmark"),
|
||||
message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await viewModel.deleteBookmark(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||
@ -179,6 +165,11 @@ struct BookmarksView: View {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
||||
Button(action: {
|
||||
// Don't navigate to detail if bookmark is pending deletion
|
||||
if viewModel.pendingDeletes[bookmark.id] != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if UIDevice.isPhone {
|
||||
selectedBookmarkId = bookmark.id
|
||||
} else {
|
||||
@ -195,20 +186,24 @@ struct BookmarksView: View {
|
||||
BookmarkCardView(
|
||||
bookmark: bookmark,
|
||||
currentState: state,
|
||||
layout: viewModel.cardLayoutStyle,
|
||||
pendingDelete: viewModel.pendingDeletes[bookmark.id],
|
||||
onArchive: { bookmark in
|
||||
Task {
|
||||
await viewModel.toggleArchive(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
onDelete: { bookmark in
|
||||
bookmarkToDelete = bookmark
|
||||
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
|
||||
},
|
||||
onToggleFavorite: { bookmark in
|
||||
Task {
|
||||
await viewModel.toggleFavorite(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
namespace: namespace
|
||||
onUndoDelete: { bookmarkId in
|
||||
viewModel.undoDelete(bookmarkId: bookmarkId)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
||||
@ -219,10 +214,14 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||
leading: 16,
|
||||
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||
trailing: 16
|
||||
))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
.matchedTransitionSource(id: bookmark.id, in: namespace)
|
||||
}
|
||||
|
||||
// Show loading indicator for pagination
|
||||
@ -256,6 +255,25 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var skeletonLoadingView: some View {
|
||||
ScrollView {
|
||||
SkeletonLoadingView(layout: viewModel.cardLayoutStyle)
|
||||
.padding(
|
||||
EdgeInsets(
|
||||
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||
leading: 16,
|
||||
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||
trailing: 16
|
||||
)
|
||||
)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.refreshable {
|
||||
await viewModel.refreshBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fabButton: some View {
|
||||
VStack {
|
||||
|
||||
@ -7,21 +7,27 @@ class BookmarksViewModel {
|
||||
private let getBooksmarksUseCase: PGetBookmarksUseCase
|
||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||
|
||||
var bookmarks: BookmarksPage?
|
||||
var isLoading = false
|
||||
var isInitialLoading = true
|
||||
var errorMessage: String?
|
||||
var currentState: BookmarkState = .unread
|
||||
var currentType = [BookmarkType.article]
|
||||
var currentTag: String? = nil
|
||||
var cardLayoutStyle: CardLayoutStyle = .magazine
|
||||
|
||||
var showingAddBookmarkFromShare = false
|
||||
var shareURL = ""
|
||||
var shareTitle = ""
|
||||
|
||||
// Undo delete functionality
|
||||
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
||||
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var limit = 20
|
||||
private var limit = 50
|
||||
private var offset = 0
|
||||
private var hasMoreData = true
|
||||
private var searchWorkItem: DispatchWorkItem?
|
||||
@ -36,13 +42,31 @@ class BookmarksViewModel {
|
||||
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
|
||||
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
|
||||
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||
|
||||
setupNotificationObserver()
|
||||
|
||||
Task {
|
||||
await loadCardLayout()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
// Listen for card layout changes
|
||||
NotificationCenter.default
|
||||
.publisher(for: NSNotification.Name("AddBookmarkFromShare"))
|
||||
.publisher(for: .cardLayoutChanged)
|
||||
.sink { notification in
|
||||
if let layout = notification.object as? CardLayoutStyle {
|
||||
Task { @MainActor in
|
||||
self.cardLayoutStyle = layout
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Listen for
|
||||
NotificationCenter.default
|
||||
.publisher(for: .addBookmarkFromShare)
|
||||
.sink { [weak self] notification in
|
||||
self?.handleShareNotification(notification)
|
||||
}
|
||||
@ -105,6 +129,7 @@ class BookmarksViewModel {
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
isInitialLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -168,14 +193,97 @@ class BookmarksViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deleteBookmark(bookmark: Bookmark) async {
|
||||
func deleteBookmarkWithUndo(bookmark: Bookmark) {
|
||||
// Don't remove from UI immediately - just mark as pending
|
||||
let pendingDelete = PendingDelete(bookmark: bookmark)
|
||||
pendingDeletes[bookmark.id] = pendingDelete
|
||||
|
||||
// Start countdown timer for this specific delete
|
||||
startDeleteCountdown(for: bookmark.id)
|
||||
|
||||
// Schedule actual delete after 3 seconds
|
||||
let deleteTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||
|
||||
// Check if not cancelled and still pending
|
||||
if !Task.isCancelled, pendingDeletes[bookmark.id] != nil {
|
||||
await executeDelete(bookmark: bookmark)
|
||||
await MainActor.run {
|
||||
// Clean up
|
||||
pendingDeletes[bookmark.id]?.timer?.invalidate()
|
||||
pendingDeletes.removeValue(forKey: bookmark.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the task in the pending delete
|
||||
pendingDeletes[bookmark.id]?.deleteTask = deleteTask
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func undoDelete(bookmarkId: String) {
|
||||
guard let pendingDelete = pendingDeletes[bookmarkId] else { return }
|
||||
|
||||
// Cancel the delete task and timer
|
||||
pendingDelete.deleteTask?.cancel()
|
||||
pendingDelete.timer?.invalidate()
|
||||
|
||||
// Remove from pending deletes
|
||||
pendingDeletes.removeValue(forKey: bookmarkId)
|
||||
}
|
||||
|
||||
private func startDeleteCountdown(for bookmarkId: String) {
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self,
|
||||
let pendingDelete = self.pendingDeletes[bookmarkId] else {
|
||||
timer.invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
pendingDelete.progress += 1.0 / 30.0 // 3 seconds / 0.1 interval = 30 steps
|
||||
|
||||
// Trigger UI update by modifying the dictionary
|
||||
self.pendingDeletes[bookmarkId] = pendingDelete
|
||||
|
||||
if pendingDelete.progress >= 1.0 {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingDeletes[bookmarkId]?.timer = timer
|
||||
}
|
||||
|
||||
private func executeDelete(bookmark: Bookmark) async {
|
||||
do {
|
||||
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
||||
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
|
||||
|
||||
} catch {
|
||||
// If delete fails, restore the bookmark
|
||||
await MainActor.run {
|
||||
errorMessage = "Error deleting bookmark"
|
||||
await loadBookmarks(state: currentState)
|
||||
if var currentBookmarks = bookmarks?.bookmarks {
|
||||
currentBookmarks.insert(bookmark, at: 0)
|
||||
bookmarks?.bookmarks = currentBookmarks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadCardLayout() async {
|
||||
cardLayoutStyle = await loadCardLayoutUseCase.execute()
|
||||
}
|
||||
}
|
||||
|
||||
class PendingDelete: Identifiable {
|
||||
let id = UUID()
|
||||
let bookmark: Bookmark
|
||||
var progress: Double = 0.0
|
||||
var timer: Timer?
|
||||
var deleteTask: Task<Void, Never>?
|
||||
|
||||
init(bookmark: Bookmark) {
|
||||
self.bookmark = bookmark
|
||||
}
|
||||
}
|
||||
|
||||
26
readeck/UI/Components/CachedAsyncImage.swift
Normal file
26
readeck/UI/Components/CachedAsyncImage.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct CachedAsyncImage: View {
|
||||
let url: URL?
|
||||
|
||||
init(url: URL?) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let url {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Color.gray.opacity(0.3)
|
||||
}
|
||||
.fade(duration: 0.25)
|
||||
.resizable()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Image("placeholder")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,5 @@
|
||||
import Foundation
|
||||
|
||||
struct Constants {
|
||||
struct Labels {
|
||||
static let pageSize = 12
|
||||
}
|
||||
// Empty for now - can be used for other constants in the future
|
||||
}
|
||||
|
||||
176
readeck/UI/Components/SkeletonLoadingView.swift
Normal file
176
readeck/UI/Components/SkeletonLoadingView.swift
Normal file
@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SkeletonLoadingView: View {
|
||||
let layout: CardLayoutStyle
|
||||
@State private var animateGradient = false
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(spacing: layout == .compact ? 8 : 12) {
|
||||
ForEach(0..<6, id: \.self) { _ in
|
||||
skeletonCard
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
animateGradient = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var skeletonCard: some View {
|
||||
switch layout {
|
||||
case .compact:
|
||||
compactSkeletonCard
|
||||
case .magazine:
|
||||
magazineSkeletonCard
|
||||
case .natural:
|
||||
naturalSkeletonCard
|
||||
}
|
||||
}
|
||||
|
||||
private var compactSkeletonCard: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
// Image placeholder
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Title placeholder
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 16)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 180, height: 16)
|
||||
|
||||
// Description placeholder
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 14)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 120, height: 14)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom info placeholder
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 80, height: 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 50, height: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var magazineSkeletonCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Image placeholder
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 140)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Title placeholder
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 16)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 200, height: 16)
|
||||
|
||||
// Info placeholders
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 80, height: 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 60, height: 12)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var naturalSkeletonCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Image placeholder
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(shimmerGradient)
|
||||
.frame(minHeight: 180)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Title placeholder
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 16)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 220, height: 16)
|
||||
|
||||
// Info placeholders
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 90, height: 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 70, height: 12)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
|
||||
}
|
||||
|
||||
private var shimmerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.3),
|
||||
Color.gray.opacity(0.1),
|
||||
Color.gray.opacity(0.3)
|
||||
],
|
||||
startPoint: animateGradient ? .topLeading : .topTrailing,
|
||||
endPoint: animateGradient ? .bottomTrailing : .bottomLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ScrollView {
|
||||
SkeletonLoadingView(layout: .magazine)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let result = FlowResult(
|
||||
in: proposal.replacingUnspecifiedDimensions().width,
|
||||
subviews: subviews,
|
||||
spacing: spacing
|
||||
)
|
||||
return result.bounds
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = FlowResult(
|
||||
in: bounds.width,
|
||||
subviews: subviews,
|
||||
spacing: spacing
|
||||
)
|
||||
|
||||
for (index, subview) in subviews.enumerated() {
|
||||
subview.place(at: CGPoint(
|
||||
x: bounds.minX + result.frames[index].minX,
|
||||
y: bounds.minY + result.frames[index].minY
|
||||
), proposal: ProposedViewSize(result.frames[index].size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FlowResult {
|
||||
var frames: [CGRect] = []
|
||||
var bounds: CGSize = .zero
|
||||
|
||||
init(in maxWidth: CGFloat, subviews: LayoutSubviews, spacing: CGFloat) {
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var lineHeight: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
|
||||
if x + size.width > maxWidth && x > 0 {
|
||||
x = 0
|
||||
y += lineHeight + spacing
|
||||
lineHeight = 0
|
||||
}
|
||||
|
||||
frames.append(CGRect(x: x, y: y, width: size.width, height: size.height))
|
||||
lineHeight = max(lineHeight, size.height)
|
||||
x += size.width + spacing
|
||||
bounds.width = max(bounds.width, x - spacing)
|
||||
}
|
||||
|
||||
bounds.height = y + lineHeight
|
||||
}
|
||||
}
|
||||
|
||||
enum AddBookmarkFieldFocus {
|
||||
case url
|
||||
case labels
|
||||
@ -27,7 +83,6 @@ struct TagManagementView: View {
|
||||
let selectedLabelsSet: Set<String>
|
||||
let searchText: Binding<String>
|
||||
let isLabelsLoading: Bool
|
||||
let availableLabelPages: [[BookmarkLabel]]
|
||||
let filteredLabels: [BookmarkLabel]
|
||||
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
||||
|
||||
@ -44,7 +99,6 @@ struct TagManagementView: View {
|
||||
selectedLabels: Set<String>,
|
||||
searchText: Binding<String>,
|
||||
isLabelsLoading: Bool,
|
||||
availableLabelPages: [[BookmarkLabel]],
|
||||
filteredLabels: [BookmarkLabel],
|
||||
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
||||
onAddCustomTag: @escaping () -> Void,
|
||||
@ -55,7 +109,6 @@ struct TagManagementView: View {
|
||||
self.selectedLabelsSet = selectedLabels
|
||||
self.searchText = searchText
|
||||
self.isLabelsLoading = isLabelsLoading
|
||||
self.availableLabelPages = availableLabelPages
|
||||
self.filteredLabels = filteredLabels
|
||||
self.searchFieldFocus = searchFieldFocus
|
||||
self.onAddCustomTag = onAddCustomTag
|
||||
@ -138,7 +191,7 @@ struct TagManagementView: View {
|
||||
.scaleEffect(0.8)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 20)
|
||||
} else if availableLabelPages.isEmpty {
|
||||
} else if allLabels.isEmpty {
|
||||
VStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
@ -150,7 +203,7 @@ struct TagManagementView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
} else {
|
||||
labelsTabView
|
||||
labelsScrollView
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
@ -158,28 +211,47 @@ struct TagManagementView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var labelsTabView: some View {
|
||||
TabView {
|
||||
ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||
ForEach(labelsPage, id: \.id) { label in
|
||||
private var labelsScrollView: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ForEach(rowLabels, id: \.id) { label in
|
||||
UnifiedLabelChip(
|
||||
label: label.name,
|
||||
isSelected: selectedLabelsSet.contains(label.name),
|
||||
isSelected: false,
|
||||
isRemovable: false,
|
||||
onTap: {
|
||||
onToggleLabel(label.name)
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(height: calculateMaxHeight())
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
|
||||
.frame(height: 180)
|
||||
.padding(.top, 10)
|
||||
|
||||
private var chunkedLabels: [[BookmarkLabel]] {
|
||||
let maxRows = 3
|
||||
let labelsPerRow = max(1, availableUnselectedLabels.count / maxRows + (availableUnselectedLabels.count % maxRows > 0 ? 1 : 0))
|
||||
return availableUnselectedLabels.chunked(into: labelsPerRow)
|
||||
}
|
||||
|
||||
private var availableUnselectedLabels: [BookmarkLabel] {
|
||||
let labelsToShow = searchText.wrappedValue.isEmpty ? allLabels : filteredLabels
|
||||
return labelsToShow.filter { !selectedLabelsSet.contains($0.name) }
|
||||
}
|
||||
|
||||
private func calculateMaxHeight() -> CGFloat {
|
||||
// Berechne Höhe für maximal 3 Reihen
|
||||
let rowHeight: CGFloat = 32 // Höhe eines Labels
|
||||
let spacing: CGFloat = 8
|
||||
let maxRows: CGFloat = 3
|
||||
return (rowHeight * maxRows) + (spacing * (maxRows - 1))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -190,11 +262,11 @@ struct TagManagementView: View {
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||
FlowLayout(spacing: 8) {
|
||||
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
|
||||
UnifiedLabelChip(
|
||||
label: label,
|
||||
isSelected: false,
|
||||
isSelected: true,
|
||||
isRemovable: true,
|
||||
onTap: {
|
||||
// No action for selected labels
|
||||
@ -210,3 +282,11 @@ struct TagManagementView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array {
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
return stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0..<Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
readeck/UI/Components/UndoToastView.swift
Normal file
68
readeck/UI/Components/UndoToastView.swift
Normal file
@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UndoToastView: View {
|
||||
let bookmarkTitle: String
|
||||
let progress: Double
|
||||
let onUndo: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Bookmark deleted")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(bookmarkTitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Undo") {
|
||||
onUndo()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.2))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.black.opacity(0.85))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
// Progress bar at bottom
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: .white.opacity(0.8)))
|
||||
.scaleEffect(y: 0.5)
|
||||
}
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
Spacer()
|
||||
UndoToastView(
|
||||
bookmarkTitle: "How to Build Great Products",
|
||||
progress: 0.6,
|
||||
onUndo: {}
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.background(Color.gray.opacity(0.3))
|
||||
}
|
||||
@ -18,6 +18,8 @@ protocol UseCaseFactory {
|
||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||
}
|
||||
|
||||
|
||||
@ -102,4 +104,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
|
||||
return OfflineBookmarkSyncUseCase()
|
||||
}
|
||||
|
||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
|
||||
return LoadCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||
}
|
||||
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,6 +77,13 @@ class MockUseCaseFactory: UseCaseFactory {
|
||||
MockAddTextToSpeechQueueUseCase()
|
||||
}
|
||||
|
||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
|
||||
MockLoadCardLayoutUseCase()
|
||||
}
|
||||
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||
MockSaveCardLayoutUseCase()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -204,6 +211,18 @@ class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
class MockLoadCardLayoutUseCase: PLoadCardLayoutUseCase {
|
||||
func execute() async -> CardLayoutStyle {
|
||||
return .magazine
|
||||
}
|
||||
}
|
||||
|
||||
class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
|
||||
func execute(layout: CardLayoutStyle) async {
|
||||
// Mock implementation - do nothing
|
||||
}
|
||||
}
|
||||
|
||||
extension Bookmark {
|
||||
static let mock: Bookmark = .init(
|
||||
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
||||
|
||||
@ -18,6 +18,7 @@ struct PhoneTabView: View {
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GlobalPlayerContainerView {
|
||||
TabView(selection: $selectedTabIndex) {
|
||||
mainTabsContent
|
||||
@ -26,6 +27,7 @@ struct PhoneTabView: View {
|
||||
.accentColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -34,9 +36,7 @@ struct PhoneTabView: View {
|
||||
@ViewBuilder
|
||||
private var mainTabsContent: some View {
|
||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||
NavigationStack {
|
||||
tabView(for: tab)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
@ -46,12 +46,10 @@ struct PhoneTabView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var moreTabContent: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
moreTabsList
|
||||
moreTabsFooter
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis")
|
||||
}
|
||||
@ -71,6 +69,7 @@ struct PhoneTabView: View {
|
||||
NavigationLink {
|
||||
tabView(for: tab)
|
||||
.navigationTitle(tab.label)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onDisappear {
|
||||
// tags and search handle navigation by own
|
||||
if tab != .tags && tab != .search {
|
||||
|
||||
@ -61,7 +61,7 @@ struct SearchBookmarksView: View {
|
||||
}
|
||||
}
|
||||
}) {
|
||||
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }, namespace: namespace)
|
||||
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in })
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
@ -91,8 +91,7 @@ struct SearchBookmarksView: View {
|
||||
set: { selectedBookmarkId = $0 }
|
||||
)
|
||||
) { bookmarkId in
|
||||
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
||||
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
||||
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||
}
|
||||
.onAppear {
|
||||
if isFirstAppearance {
|
||||
|
||||
221
readeck/UI/Settings/AppearanceSettingsView.swift
Normal file
221
readeck/UI/Settings/AppearanceSettingsView.swift
Normal file
@ -0,0 +1,221 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppearanceSettingsView: View {
|
||||
@State private var selectedCardLayout: CardLayoutStyle = .magazine
|
||||
@State private var selectedTheme: Theme = .system
|
||||
|
||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
||||
|
||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Appearance", icon: "paintbrush")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Theme Section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Theme")
|
||||
.font(.headline)
|
||||
Picker("Theme", selection: $selectedTheme) {
|
||||
ForEach(Theme.allCases, id: \.self) { theme in
|
||||
Text(theme.displayName).tag(theme)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: selectedTheme) {
|
||||
saveThemeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Card Layout Section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Card Layout")
|
||||
.font(.headline)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
||||
CardLayoutPreview(
|
||||
layout: layout,
|
||||
isSelected: selectedCardLayout == layout
|
||||
) {
|
||||
selectedCardLayout = layout
|
||||
saveCardLayoutSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSettings() {
|
||||
// Load theme setting
|
||||
let themeString = UserDefaults.standard.string(forKey: "selectedTheme") ?? "system"
|
||||
selectedTheme = Theme(rawValue: themeString) ?? .system
|
||||
|
||||
// Load card layout setting
|
||||
Task {
|
||||
selectedCardLayout = await loadCardLayoutUseCase.execute()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveThemeSettings() {
|
||||
UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme")
|
||||
}
|
||||
|
||||
private func saveCardLayoutSettings() {
|
||||
Task {
|
||||
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
||||
// Notify other parts of the app about the change
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .cardLayoutChanged, object: selectedCardLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct CardLayoutPreview: View {
|
||||
let layout: CardLayoutStyle
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: 12) {
|
||||
// Visual Preview
|
||||
switch layout {
|
||||
case .compact:
|
||||
// Compact: Small image on left, content on right
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.8))
|
||||
.frame(height: 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.6))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: 60)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.4))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: 40)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: 80, height: 50)
|
||||
|
||||
case .magazine:
|
||||
VStack(spacing: 4) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(height: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.8))
|
||||
.frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.6))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: 40)
|
||||
|
||||
Text("Fixed 140px")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.padding(6)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: 80, height: 65)
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
|
||||
case .natural:
|
||||
VStack(spacing: 3) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(height: 38)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.8))
|
||||
.frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.6))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: 35)
|
||||
|
||||
Text("Original ratio")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.padding(6)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: 80, height: 75) // Höher als Magazine
|
||||
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(layout.displayName)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(layout.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
.font(.title2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
AppearanceSettingsView()
|
||||
.cardStyle()
|
||||
.padding()
|
||||
}
|
||||
148
readeck/UI/Settings/CacheSettingsView.swift
Normal file
148
readeck/UI/Settings/CacheSettingsView.swift
Normal file
@ -0,0 +1,148 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct CacheSettingsView: View {
|
||||
@State private var cacheSize: String = "0 MB"
|
||||
@State private var maxCacheSize: Double = 200
|
||||
@State private var isClearing: Bool = false
|
||||
@State private var showClearAlert: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Cache Settings", icon: "internaldrive")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Current Cache Size")
|
||||
.foregroundColor(.primary)
|
||||
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Refresh") {
|
||||
updateCacheSize()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Max Cache Size")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Text("\(Int(maxCacheSize)) MB")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
||||
Text("Max Cache Size")
|
||||
}
|
||||
.onChange(of: maxCacheSize) { _, newValue in
|
||||
updateMaxCacheSize(newValue)
|
||||
}
|
||||
.accentColor(.blue)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(action: {
|
||||
showClearAlert = true
|
||||
}) {
|
||||
HStack {
|
||||
if isClearing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.frame(width: 24)
|
||||
} else {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
.frame(width: 24)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Clear Cache")
|
||||
.foregroundColor(isClearing ? .secondary : .red)
|
||||
Text("Remove all cached images")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(isClearing)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateCacheSize()
|
||||
loadMaxCacheSize()
|
||||
}
|
||||
.alert("Clear Cache", isPresented: $showClearAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Clear", role: .destructive) {
|
||||
clearCache()
|
||||
}
|
||||
} message: {
|
||||
Text("This will remove all cached images. They will be downloaded again when needed.")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCacheSize() {
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let size):
|
||||
let mbSize = Double(size) / (1024 * 1024)
|
||||
self.cacheSize = String(format: "%.1f MB", mbSize)
|
||||
case .failure:
|
||||
self.cacheSize = "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMaxCacheSize() {
|
||||
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
||||
if let savedSize = savedSize {
|
||||
maxCacheSize = Double(savedSize) / (1024 * 1024)
|
||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize
|
||||
} else {
|
||||
maxCacheSize = 200
|
||||
let defaultBytes = UInt(200 * 1024 * 1024)
|
||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes
|
||||
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMaxCacheSize(_ newSize: Double) {
|
||||
let bytes = UInt(newSize * 1024 * 1024)
|
||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
||||
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
||||
}
|
||||
|
||||
private func clearCache() {
|
||||
isClearing = true
|
||||
|
||||
KingfisherManager.shared.cache.clearDiskCache {
|
||||
DispatchQueue.main.async {
|
||||
self.isClearing = false
|
||||
self.updateCacheSize()
|
||||
}
|
||||
}
|
||||
|
||||
KingfisherManager.shared.cache.clearMemoryCache()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CacheSettingsView()
|
||||
.cardStyle()
|
||||
.padding()
|
||||
}
|
||||
@ -15,17 +15,9 @@ struct FontSettingsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Header
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "textformat")
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Text("Font")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Font Settings", icon: "textformat")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Font Family Picker
|
||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||
|
||||
@ -21,6 +21,12 @@ struct SettingsContainerView: View {
|
||||
FontSettingsView()
|
||||
.cardStyle()
|
||||
|
||||
AppearanceSettingsView()
|
||||
.cardStyle()
|
||||
|
||||
CacheSettingsView()
|
||||
.cardStyle()
|
||||
|
||||
SettingsGeneralView()
|
||||
.cardStyle()
|
||||
|
||||
@ -103,9 +109,19 @@ struct SettingsContainerView: View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Developer: Ilyas Hallak")
|
||||
HStack(spacing: 4) {
|
||||
Text("Developer:")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
Button("Ilyas Hallak") {
|
||||
if let url = URL(string: "https://ilyashallak.de") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundColor(.blue)
|
||||
.underline()
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "globe")
|
||||
|
||||
@ -19,23 +19,6 @@ struct SettingsGeneralView: View {
|
||||
SectionHeader(title: "General Settings", icon: "gear")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Theme
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Theme")
|
||||
.font(.headline)
|
||||
Picker("Theme", selection: $viewModel.selectedTheme) {
|
||||
ForEach(Theme.allCases, id: \.self) { theme in
|
||||
Text(theme.displayName).tag(theme)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedTheme) {
|
||||
Task {
|
||||
await viewModel.saveGeneralSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("General")
|
||||
.font(.headline)
|
||||
@ -78,38 +61,6 @@ struct SettingsGeneralView: View {
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
}
|
||||
|
||||
// Data Management
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Data Management")
|
||||
.font(.headline)
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
// await viewModel.clearCache()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
Text("Clear cache")
|
||||
.foregroundColor(.red)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
// await viewModel.resetSettings()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.foregroundColor(.red)
|
||||
Text("Reset settings")
|
||||
.foregroundColor(.red)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
if let successMessage = viewModel.successMessage {
|
||||
HStack {
|
||||
|
||||
@ -52,7 +52,7 @@ class SettingsGeneralViewModel {
|
||||
successMessage = "Settings saved"
|
||||
|
||||
// send notification to apply settings to the app
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SettingsChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
} catch {
|
||||
errorMessage = "Error saving settings"
|
||||
}
|
||||
|
||||
@ -141,16 +141,18 @@ struct SettingsServerView: View {
|
||||
Button(action: {
|
||||
showingLogoutAlert = true
|
||||
}) {
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.font(.caption)
|
||||
Text("Logout")
|
||||
.fontWeight(.semibold)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.red)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray5))
|
||||
.foregroundColor(.secondary)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,7 +67,7 @@ class SettingsServerViewModel {
|
||||
isLoggedIn = true
|
||||
successMessage = "Server settings saved and successfully logged in."
|
||||
try await SettingsRepository().saveHasFinishedSetup(true)
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||
} catch {
|
||||
errorMessage = "Connection or login failed: \(error.localizedDescription)"
|
||||
isLoggedIn = false
|
||||
@ -80,7 +80,7 @@ class SettingsServerViewModel {
|
||||
try await logoutUseCase.execute()
|
||||
isLoggedIn = false
|
||||
successMessage = "Logged out"
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||
} catch {
|
||||
errorMessage = "Error logging out"
|
||||
}
|
||||
|
||||
20
readeck/UI/Utils/NotificationNames.swift
Normal file
20
readeck/UI/Utils/NotificationNames.swift
Normal file
@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
// MARK: - App Lifecycle
|
||||
static let settingsChanged = Notification.Name("SettingsChanged")
|
||||
static let setupStatusChanged = Notification.Name("SetupStatusChanged")
|
||||
|
||||
// MARK: - Authentication
|
||||
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
|
||||
|
||||
// MARK: - Network
|
||||
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
|
||||
|
||||
// MARK: - UI Interactions
|
||||
static let dismissKeyboard = Notification.Name("DismissKeyboard")
|
||||
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
|
||||
|
||||
// MARK: - User Preferences
|
||||
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
|
||||
}
|
||||
@ -10,13 +10,13 @@ import netfox
|
||||
|
||||
@main
|
||||
struct readeckApp: App {
|
||||
@State private var hasFinishedSetup = true
|
||||
@StateObject private var appViewModel = AppViewModel()
|
||||
@StateObject private var appSettings = AppSettings()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if hasFinishedSetup {
|
||||
if appViewModel.hasFinishedSetup {
|
||||
MainTabView()
|
||||
} else {
|
||||
SettingsServerView()
|
||||
@ -32,25 +32,19 @@ struct readeckApp: App {
|
||||
// Initialize server connectivity monitoring
|
||||
_ = ServerConnectivity.shared
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
await loadAppSettings()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
await loadAppSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSetupStatus() async {
|
||||
private func loadAppSettings() async {
|
||||
let settingsRepository = SettingsRepository()
|
||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
||||
let settings = try? await settingsRepository.loadSettings()
|
||||
await MainActor.run {
|
||||
appSettings.settings = settings
|
||||
|
||||
@ -51,6 +51,7 @@
|
||||
<attribute name="src" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="cardLayoutStyle" optional="YES" attributeType="String"/>
|
||||
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="fontFamily" optional="YES" attributeType="String"/>
|
||||
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user