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/test_output
|
||||||
fastlane/.env.default
|
fastlane/.env.default
|
||||||
fastlane/AuthKey_JZJCQWW9N3.p8
|
fastlane/AuthKey_JZJCQWW9N3.p8
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
documentation/
|
||||||
|
|||||||
@ -6,7 +6,7 @@ struct ShareBookmarkView: View {
|
|||||||
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
||||||
|
|
||||||
private func dismissKeyboard() {
|
private func dismissKeyboard() {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
|
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -140,7 +140,6 @@ struct ShareBookmarkView: View {
|
|||||||
selectedLabels: viewModel.selectedLabels,
|
selectedLabels: viewModel.selectedLabels,
|
||||||
searchText: $viewModel.searchText,
|
searchText: $viewModel.searchText,
|
||||||
isLabelsLoading: false,
|
isLabelsLoading: false,
|
||||||
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
|
|
||||||
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
|
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
|
||||||
searchFieldFocus: $focusedField,
|
searchFieldFocus: $focusedField,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
|
|||||||
@ -15,13 +15,12 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
let extensionContext: NSExtensionContext?
|
let extensionContext: NSExtensionContext?
|
||||||
|
|
||||||
private let logger = Logger.viewModel
|
private let logger = Logger.viewModel
|
||||||
|
|
||||||
// Computed properties for pagination
|
|
||||||
var availableLabels: [BookmarkLabelDto] {
|
var availableLabels: [BookmarkLabelDto] {
|
||||||
return labels.filter { !selectedLabels.contains($0.name) }
|
return labels.filter { !selectedLabels.contains($0.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed property for filtered labels based on search text
|
// filtered labels based on search text
|
||||||
var filteredLabels: [BookmarkLabelDto] {
|
var filteredLabels: [BookmarkLabelDto] {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
return availableLabels
|
return availableLabels
|
||||||
|
|||||||
@ -34,7 +34,7 @@ class ShareViewController: UIViewController {
|
|||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(dismissKeyboard),
|
selector: #selector(dismissKeyboard),
|
||||||
name: NSNotification.Name("DismissKeyboard"),
|
name: .dismissKeyboard,
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,11 @@ class SimpleAPI {
|
|||||||
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
|
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
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"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
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)
|
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
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"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
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 */
|
/* Begin PBXBuildFile section */
|
||||||
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
||||||
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
||||||
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
|
||||||
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -67,7 +66,6 @@
|
|||||||
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -93,6 +91,7 @@
|
|||||||
UI/Components/CustomTextFieldStyle.swift,
|
UI/Components/CustomTextFieldStyle.swift,
|
||||||
UI/Components/TagManagementView.swift,
|
UI/Components/TagManagementView.swift,
|
||||||
UI/Components/UnifiedLabelChip.swift,
|
UI/Components/UnifiedLabelChip.swift,
|
||||||
|
UI/Utils/NotificationNames.swift,
|
||||||
);
|
);
|
||||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||||
};
|
};
|
||||||
@ -147,6 +146,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||||
);
|
);
|
||||||
@ -172,7 +172,6 @@
|
|||||||
5D45F9BF2DF858680048D5B8 = {
|
5D45F9BF2DF858680048D5B8 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
|
|
||||||
5D45F9CA2DF858680048D5B8 /* readeck */,
|
5D45F9CA2DF858680048D5B8 /* readeck */,
|
||||||
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
|
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
|
||||||
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
|
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
|
||||||
@ -240,6 +239,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||||
|
5D9D95482E623668009AF769 /* Kingfisher */,
|
||||||
);
|
);
|
||||||
productName = readeck;
|
productName = readeck;
|
||||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||||
@ -330,6 +330,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
||||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
||||||
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
||||||
@ -349,7 +350,6 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -357,7 +357,6 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -436,7 +435,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -469,7 +468,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -624,7 +623,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -668,7 +667,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -853,6 +852,14 @@
|
|||||||
minimumVersion = 1.21.0;
|
minimumVersion = 1.21.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 8.5.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
|
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
|
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
|
||||||
@ -869,6 +876,11 @@
|
|||||||
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
||||||
productName = netfox;
|
productName = netfox;
|
||||||
};
|
};
|
||||||
|
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
|
productName = Kingfisher;
|
||||||
|
};
|
||||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
|
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
|
"originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "kingfisher",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/onevcat/Kingfisher.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2015fda791daa72c8058619545a593bf8c1dd59f",
|
||||||
|
"version" : "8.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "netfox",
|
"identity" : "netfox",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -2,16 +2,7 @@
|
|||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
|
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal"
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@ -41,6 +41,14 @@ class API: PAPI {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleUnauthorizedResponse(_ statusCode: Int) {
|
||||||
|
if statusCode == 401 {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeJSONRequestWithHeaders<T: Codable>(
|
private func makeJSONRequestWithHeaders<T: Codable>(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
@ -74,6 +82,7 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +123,7 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,6 +156,7 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,6 +192,7 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||||
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
@ -342,6 +354,7 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||||
logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
@ -379,6 +392,7 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||||
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,7 +119,7 @@ class OfflineSyncManager: ObservableObject {
|
|||||||
func startAutoSync() {
|
func startAutoSync() {
|
||||||
// Monitor server connectivity and auto-sync when server becomes reachable
|
// Monitor server connectivity and auto-sync when server becomes reachable
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: NSNotification.Name("ServerDidBecomeAvailable"),
|
forName: .serverDidBecomeAvailable,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
|
|||||||
@ -12,6 +12,7 @@ struct Settings {
|
|||||||
var hasFinishedSetup: Bool = false
|
var hasFinishedSetup: Bool = false
|
||||||
var enableTTS: Bool? = nil
|
var enableTTS: Bool? = nil
|
||||||
var theme: Theme? = nil
|
var theme: Theme? = nil
|
||||||
|
var cardLayoutStyle: CardLayoutStyle? = nil
|
||||||
|
|
||||||
var isLoggedIn: Bool {
|
var isLoggedIn: Bool {
|
||||||
token != nil && !token!.isEmpty
|
token != nil && !token!.isEmpty
|
||||||
@ -31,6 +32,8 @@ protocol PSettingsRepository {
|
|||||||
func savePassword(_ password: String) async throws
|
func savePassword(_ password: String) async throws
|
||||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
||||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||||
|
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
|
||||||
|
func loadCardLayoutStyle() async throws -> CardLayoutStyle
|
||||||
var hasFinishedSetup: Bool { get }
|
var hasFinishedSetup: Bool { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +82,10 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
existingSettings.theme = theme.rawValue
|
existingSettings.theme = theme.rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let cardLayoutStyle = settings.cardLayoutStyle {
|
||||||
|
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
try context.save()
|
try context.save()
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
} catch {
|
} catch {
|
||||||
@ -115,7 +122,8 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
|
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
|
||||||
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
||||||
enableTTS: settingEntity?.enableTTS,
|
enableTTS: settingEntity?.enableTTS,
|
||||||
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue)
|
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
|
||||||
|
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue)
|
||||||
)
|
)
|
||||||
continuation.resume(returning: settings)
|
continuation.resume(returning: settings)
|
||||||
} catch {
|
} catch {
|
||||||
@ -160,7 +168,7 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
self.hasFinishedSetup = true
|
self.hasFinishedSetup = true
|
||||||
// Notification senden, dass sich der Setup-Status geändert hat
|
// Notification senden, dass sich der Setup-Status geändert hat
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -174,7 +182,7 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
if !token.isEmpty {
|
if !token.isEmpty {
|
||||||
self.hasFinishedSetup = true
|
self.hasFinishedSetup = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,7 +200,7 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
self.hasFinishedSetup = hasFinishedSetup
|
self.hasFinishedSetup = hasFinishedSetup
|
||||||
// Notification senden, dass sich der Setup-Status geändert hat
|
// Notification senden, dass sich der Setup-Status geändert hat
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
}
|
}
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
}
|
}
|
||||||
@ -206,4 +214,45 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
|
||||||
|
let context = coreDataManager.context
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
context.perform {
|
||||||
|
do {
|
||||||
|
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||||
|
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
|
||||||
|
|
||||||
|
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
continuation.resume()
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCardLayoutStyle() async throws -> CardLayoutStyle {
|
||||||
|
let context = coreDataManager.context
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
context.perform {
|
||||||
|
do {
|
||||||
|
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let settingEntities = try context.fetch(fetchRequest)
|
||||||
|
let settingEntity = settingEntities.first
|
||||||
|
|
||||||
|
let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine
|
||||||
|
continuation.resume(returning: cardLayoutStyle)
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class ServerConnectivity: ObservableObject {
|
|||||||
|
|
||||||
// Notify when server becomes available
|
// Notify when server becomes available
|
||||||
if !wasReachable && serverReachable {
|
if !wasReachable && serverReachable {
|
||||||
NotificationCenter.default.post(name: 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
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 16) {
|
||||||
urlField
|
urlField
|
||||||
.id("urlField")
|
.id("urlField")
|
||||||
Spacer()
|
|
||||||
.frame(height: 40)
|
|
||||||
.id("labelsOffset")
|
.id("labelsOffset")
|
||||||
labelsField
|
labelsField
|
||||||
.id("labelsField")
|
.id("labelsField")
|
||||||
@ -160,10 +158,11 @@ struct AddBookmarkView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(12)
|
||||||
.background(Color(.systemGray6))
|
.background(Color(.systemGray6))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +182,6 @@ struct AddBookmarkView: View {
|
|||||||
selectedLabels: viewModel.selectedLabels,
|
selectedLabels: viewModel.selectedLabels,
|
||||||
searchText: $viewModel.searchText,
|
searchText: $viewModel.searchText,
|
||||||
isLabelsLoading: viewModel.isLabelsLoading,
|
isLabelsLoading: viewModel.isLabelsLoading,
|
||||||
availableLabelPages: viewModel.availableLabelPages,
|
|
||||||
filteredLabels: viewModel.filteredLabels,
|
filteredLabels: viewModel.filteredLabels,
|
||||||
searchFieldFocus: $focusedField,
|
searchFieldFocus: $focusedField,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
|
|||||||
@ -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
|
// MARK: - Labels Management
|
||||||
|
|
||||||
@MainActor
|
@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 {
|
struct BookmarkDetailView: View {
|
||||||
let bookmarkId: String
|
let bookmarkId: String
|
||||||
let namespace: Namespace.ID?
|
|
||||||
|
|
||||||
// MARK: - States
|
// MARK: - States
|
||||||
|
|
||||||
@ -24,11 +23,10 @@ struct BookmarkDetailView: View {
|
|||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
private let headerHeight: CGFloat = 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.bookmarkId = bookmarkId
|
||||||
self.namespace = namespace
|
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.webViewHeight = webViewHeight
|
self.webViewHeight = webViewHeight
|
||||||
self.showingFontSettings = showingFontSettings
|
self.showingFontSettings = showingFontSettings
|
||||||
@ -66,7 +64,7 @@ struct BookmarkDetailView: View {
|
|||||||
})
|
})
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
.cornerRadius(14)
|
.cornerRadius(14)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal, 4)
|
||||||
.animation(.easeInOut, value: webViewHeight)
|
.animation(.easeInOut, value: webViewHeight)
|
||||||
} else if viewModel.isLoadingArticle {
|
} else if viewModel.isLoadingArticle {
|
||||||
ProgressView("Loading article...")
|
ProgressView("Loading article...")
|
||||||
@ -78,7 +76,7 @@ struct BookmarkDetailView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
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())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -191,40 +189,11 @@ struct BookmarkDetailView: View {
|
|||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let offset = geo.frame(in: .global).minY
|
let offset = geo.frame(in: .global).minY
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
|
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||||
image
|
.scaledToFit()
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||||
.clipped()
|
.clipped()
|
||||||
.offset(y: (offset > 0 ? -offset : 0))
|
.offset(y: (offset > 0 ? -offset : 0))
|
||||||
.if(namespace != nil) { view in
|
|
||||||
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
|
|
||||||
}
|
|
||||||
} placeholder: {
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.gray.opacity(0.4))
|
|
||||||
.frame(width: geometry.size.width, height: headerHeight)
|
|
||||||
.if(namespace != nil) { view in
|
|
||||||
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Gradient overlay für bessere Button-Sichtbarkeit
|
|
||||||
LinearGradient(
|
|
||||||
gradient: Gradient(colors: [
|
|
||||||
Color.black.opacity(1.0),
|
|
||||||
Color.black.opacity(0.9),
|
|
||||||
Color.black.opacity(0.7),
|
|
||||||
Color.black.opacity(0.4),
|
|
||||||
Color.black.opacity(0.2),
|
|
||||||
Color.clear
|
|
||||||
]),
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
.frame(height: 240)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.offset(y: (offset > 0 ? -offset : 0))
|
|
||||||
|
|
||||||
// Tap area and zoom icon
|
// Tap area and zoom icon
|
||||||
VStack {
|
VStack {
|
||||||
|
|||||||
@ -61,7 +61,6 @@ struct BookmarkLabelsView: View {
|
|||||||
selectedLabels: Set(viewModel.currentLabels),
|
selectedLabels: Set(viewModel.currentLabels),
|
||||||
searchText: $viewModel.searchText,
|
searchText: $viewModel.searchText,
|
||||||
isLabelsLoading: viewModel.isInitialLoading,
|
isLabelsLoading: viewModel.isInitialLoading,
|
||||||
availableLabelPages: viewModel.availableLabelPages,
|
|
||||||
filteredLabels: viewModel.filteredLabels,
|
filteredLabels: viewModel.filteredLabels,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@ -10,46 +10,24 @@ class BookmarkLabelsViewModel {
|
|||||||
var isInitialLoading = false
|
var isInitialLoading = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var showErrorAlert = false
|
var showErrorAlert = false
|
||||||
var currentLabels: [String] = [] {
|
var currentLabels: [String] = []
|
||||||
didSet {
|
|
||||||
if oldValue != currentLabels {
|
|
||||||
calculatePages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var newLabelText = ""
|
var newLabelText = ""
|
||||||
var searchText = "" {
|
var searchText = ""
|
||||||
didSet {
|
|
||||||
if oldValue != searchText {
|
|
||||||
calculatePages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var allLabels: [BookmarkLabel] = [] {
|
var allLabels: [BookmarkLabel] = []
|
||||||
didSet {
|
|
||||||
if oldValue != allLabels {
|
|
||||||
calculatePages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var labelPages: [[BookmarkLabel]] = []
|
|
||||||
|
|
||||||
// Cached properties to avoid recomputation
|
|
||||||
private var _availableLabels: [BookmarkLabel] = []
|
|
||||||
private var _filteredLabels: [BookmarkLabel] = []
|
|
||||||
|
|
||||||
var availableLabels: [BookmarkLabel] {
|
var availableLabels: [BookmarkLabel] {
|
||||||
return _availableLabels
|
return allLabels.filter { !currentLabels.contains($0.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var filteredLabels: [BookmarkLabel] {
|
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] = []) {
|
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
|
||||||
self.currentLabels = initialLabels
|
self.currentLabels = initialLabels
|
||||||
|
|
||||||
@ -70,8 +48,6 @@ class BookmarkLabelsViewModel {
|
|||||||
errorMessage = "failed to load labels"
|
errorMessage = "failed to load labels"
|
||||||
showErrorAlert = true
|
showErrorAlert = true
|
||||||
}
|
}
|
||||||
|
|
||||||
calculatePages()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -143,36 +119,4 @@ class BookmarkLabelsViewModel {
|
|||||||
func updateLabels(_ labels: [String]) {
|
func updateLabels(_ labels: [String]) {
|
||||||
currentLabels = labels
|
currentLabels = labels
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculatePages() {
|
|
||||||
let pageSize = Constants.Labels.pageSize
|
|
||||||
|
|
||||||
// Update cached available labels
|
|
||||||
_availableLabels = allLabels.filter { !currentLabels.contains($0.name) }
|
|
||||||
|
|
||||||
// Update cached filtered labels
|
|
||||||
if searchText.isEmpty {
|
|
||||||
_filteredLabels = _availableLabels
|
|
||||||
} else {
|
|
||||||
_filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate pages for all labels
|
|
||||||
if allLabels.count <= pageSize {
|
|
||||||
labelPages = [allLabels]
|
|
||||||
} else {
|
|
||||||
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
|
|
||||||
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate pages for filtered labels
|
|
||||||
if _filteredLabels.count <= pageSize {
|
|
||||||
availableLabelPages = [_filteredLabels]
|
|
||||||
} else {
|
|
||||||
availableLabelPages = stride(from: 0, to: _filteredLabels.count, by: pageSize).map {
|
|
||||||
Array(_filteredLabels[$0..<min($0 + pageSize, _filteredLabels.count)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,102 +17,91 @@ struct ImageViewerView: View {
|
|||||||
Color.black
|
Color.black
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
AsyncImage(url: URL(string: imageUrl)) { image in
|
CachedAsyncImage(url: URL(string: imageUrl))
|
||||||
image
|
.scaledToFit()
|
||||||
.resizable()
|
.scaleEffect(scale)
|
||||||
.scaledToFit()
|
.offset(offset)
|
||||||
.scaleEffect(scale)
|
.offset(dragOffset)
|
||||||
.offset(offset)
|
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
|
||||||
.offset(dragOffset)
|
.gesture(
|
||||||
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
|
SimultaneousGesture(
|
||||||
.gesture(
|
MagnificationGesture()
|
||||||
SimultaneousGesture(
|
.onChanged { value in
|
||||||
MagnificationGesture()
|
let delta = value / lastScale
|
||||||
.onChanged { value in
|
lastScale = value
|
||||||
let delta = value / lastScale
|
scale = min(max(scale * delta, 1), 4)
|
||||||
lastScale = value
|
|
||||||
scale = min(max(scale * delta, 1), 4)
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
lastScale = 1.0
|
|
||||||
if scale < 1 {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
scale = 1
|
|
||||||
offset = .zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if scale > 4 {
|
|
||||||
scale = 4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
if scale > 1 {
|
|
||||||
let newOffset = CGSize(
|
|
||||||
width: lastOffset.width + value.translation.width,
|
|
||||||
height: lastOffset.height + value.translation.height
|
|
||||||
)
|
|
||||||
offset = newOffset
|
|
||||||
} else {
|
|
||||||
// Dismiss gesture when not zoomed
|
|
||||||
dragOffset = value.translation
|
|
||||||
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
|
||||||
if dragDistance > 50 {
|
|
||||||
isDraggingToDismiss = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
if scale <= 1 {
|
|
||||||
lastOffset = offset
|
|
||||||
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
|
||||||
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
|
|
||||||
|
|
||||||
if dragDistance > 100 || velocity > 500 {
|
|
||||||
dismiss()
|
|
||||||
} else {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
dragOffset = .zero
|
|
||||||
isDraggingToDismiss = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastOffset = offset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.onTapGesture(count: 2) {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
if scale > 1 {
|
|
||||||
scale = 1
|
|
||||||
offset = .zero
|
|
||||||
lastOffset = .zero
|
|
||||||
} else {
|
|
||||||
scale = 2
|
|
||||||
}
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastScale = 1.0
|
||||||
|
if scale < 1 {
|
||||||
|
withAnimation(.spring()) {
|
||||||
|
scale = 1
|
||||||
|
offset = .zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scale > 4 {
|
||||||
|
scale = 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
if scale > 1 {
|
||||||
|
let newOffset = CGSize(
|
||||||
|
width: lastOffset.width + value.translation.width,
|
||||||
|
height: lastOffset.height + value.translation.height
|
||||||
|
)
|
||||||
|
offset = newOffset
|
||||||
|
} else {
|
||||||
|
// Dismiss gesture when not zoomed
|
||||||
|
dragOffset = value.translation
|
||||||
|
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
||||||
|
if dragDistance > 50 {
|
||||||
|
isDraggingToDismiss = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
if scale <= 1 {
|
||||||
|
lastOffset = offset
|
||||||
|
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
||||||
|
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
|
||||||
|
|
||||||
|
if dragDistance > 100 || velocity > 500 {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
withAnimation(.spring()) {
|
||||||
|
dragOffset = .zero
|
||||||
|
isDraggingToDismiss = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastOffset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
withAnimation(.spring()) {
|
||||||
|
if scale > 1 {
|
||||||
|
scale = 1
|
||||||
|
offset = .zero
|
||||||
|
lastOffset = .zero
|
||||||
|
} else {
|
||||||
|
scale = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} placeholder: {
|
}
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(1.5)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.toolbar {
|
||||||
.toolbar {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
Button("Close") {
|
||||||
Button("Close") {
|
dismiss()
|
||||||
dismiss()
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ImageViewerView(imageUrl: "https://example.com/image.jpg")
|
|
||||||
}
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
@ -12,35 +13,191 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct BookmarkCardView: View {
|
struct BookmarkCardView: View {
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
let bookmark: Bookmark
|
let bookmark: Bookmark
|
||||||
let currentState: BookmarkState
|
let currentState: BookmarkState
|
||||||
|
let layout: CardLayoutStyle
|
||||||
|
let pendingDelete: PendingDelete?
|
||||||
let onArchive: (Bookmark) -> Void
|
let onArchive: (Bookmark) -> Void
|
||||||
let onDelete: (Bookmark) -> Void
|
let onDelete: (Bookmark) -> Void
|
||||||
let onToggleFavorite: (Bookmark) -> Void
|
let onToggleFavorite: (Bookmark) -> Void
|
||||||
let 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 {
|
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) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
AsyncImage(url: imageURL) { image in
|
CachedAsyncImage(url: imageURL)
|
||||||
image
|
.aspectRatio(contentMode: .fill)
|
||||||
.resizable()
|
.frame(height: 140)
|
||||||
.aspectRatio(contentMode: .fill)
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.frame(height: 120)
|
|
||||||
} placeholder: {
|
|
||||||
|
|
||||||
Image(R.image.placeholder.name)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(height: 120)
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.if(namespace != nil) { view in
|
|
||||||
view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!)
|
|
||||||
}
|
|
||||||
|
|
||||||
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -77,15 +234,12 @@ struct BookmarkCardView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
||||||
// Published date
|
|
||||||
if let publishedDate = formattedPublishedDate {
|
if let publishedDate = formattedPublishedDate {
|
||||||
HStack {
|
HStack {
|
||||||
Label(publishedDate, systemImage: "calendar")
|
Label(publishedDate, systemImage: "calendar")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
Spacer() // show spacer only if we have the published Date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||||
@ -107,41 +261,93 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
.background(Color(R.color.bookmark_list_bg))
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.shadow(color: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1)
|
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||||
Button("Delete", role: .destructive) {
|
}
|
||||||
onDelete(bookmark)
|
|
||||||
}
|
private var naturalLayoutView: some View {
|
||||||
.tint(.red)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
}
|
ZStack(alignment: .bottomTrailing) {
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
CachedAsyncImage(url: imageURL)
|
||||||
// Archive (left)
|
.aspectRatio(contentMode: .fit)
|
||||||
Button {
|
.frame(minHeight: 180)
|
||||||
onArchive(bookmark)
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
} label: {
|
|
||||||
if currentState == .archived {
|
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
||||||
Label("Restore", systemImage: "tray.and.arrow.up")
|
ZStack {
|
||||||
} else {
|
Circle()
|
||||||
Label("Archive", systemImage: "archivebox")
|
.fill(Color(.systemBackground))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(bookmark.readProgress) / 100)
|
||||||
|
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
|
Text("\(bookmark.readProgress)")
|
||||||
|
.font(.caption2)
|
||||||
|
.bold()
|
||||||
|
Text("%")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.baselineOffset(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(currentState == .archived ? .blue : .orange)
|
|
||||||
|
|
||||||
Button {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
onToggleFavorite(bookmark)
|
Text(bookmark.title)
|
||||||
} label: {
|
.font(.headline)
|
||||||
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
.fontWeight(.semibold)
|
||||||
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
if let publishedDate = formattedPublishedDate {
|
||||||
|
HStack {
|
||||||
|
Label(publishedDate, systemImage: "calendar")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||||
|
Label("\(readingTime) min", systemImage: "clock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if !bookmark.siteName.isEmpty {
|
||||||
|
Label(bookmark.siteName, systemImage: "globe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||||
|
.onTapGesture {
|
||||||
|
SafariUtil.openInSafari(url: bookmark.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.tint(bookmark.isMarked ? .gray : .pink)
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -156,13 +362,10 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
|
|
||||||
guard let date = formatter.date(from: published) else {
|
guard let date = formatter.date(from: published) else {
|
||||||
// Fallback without milliseconds
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
|
||||||
guard let fallbackDate = formatter.date(from: published) else {
|
guard let fallbackDate = formatter.date(from: published) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -173,18 +376,19 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func formatDate(_ date: Date) -> String {
|
private func formatDate(_ date: Date) -> String {
|
||||||
let now = Date()
|
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
// Today
|
// Today
|
||||||
if calendar.isDateInToday(date) {
|
if calendar.isDate(date, inSameDayAs: now) {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.timeStyle = .short
|
formatter.timeStyle = .short
|
||||||
return "Today, \(formatter.string(from: date))"
|
return "Today, \(formatter.string(from: date))"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yesterday
|
// Yesterday
|
||||||
if calendar.isDateInYesterday(date) {
|
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
|
||||||
|
calendar.isDate(date, inSameDayAs: yesterday) {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.timeStyle = .short
|
formatter.timeStyle = .short
|
||||||
return "Yesterday, \(formatter.string(from: date))"
|
return "Yesterday, \(formatter.string(from: date))"
|
||||||
@ -211,13 +415,8 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var imageURL: URL? {
|
private var imageURL: URL? {
|
||||||
// Prioritize image, then thumbnail, then icon
|
|
||||||
if let imageUrl = bookmark.resources.image?.src {
|
if let imageUrl = bookmark.resources.image?.src {
|
||||||
return URL(string: imageUrl)
|
return URL(string: imageUrl)
|
||||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
|
||||||
return URL(string: thumbnailUrl)
|
|
||||||
} else if let iconUrl = bookmark.resources.icon?.src {
|
|
||||||
return URL(string: iconUrl)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -229,11 +428,9 @@ struct IconBadge: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Image(systemName: systemName)
|
Image(systemName: systemName)
|
||||||
.font(.caption2)
|
.frame(width: 20, height: 20)
|
||||||
.padding(6)
|
.background(color)
|
||||||
.background(color.opacity(0.2))
|
.foregroundColor(.white)
|
||||||
.foregroundColor(color)
|
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4,8 +4,6 @@ import SwiftUI
|
|||||||
|
|
||||||
struct BookmarksView: View {
|
struct BookmarksView: View {
|
||||||
|
|
||||||
@Namespace private var namespace
|
|
||||||
|
|
||||||
// MARK: States
|
// MARK: States
|
||||||
|
|
||||||
@State private var viewModel: BookmarksViewModel
|
@State private var viewModel: BookmarksViewModel
|
||||||
@ -14,7 +12,6 @@ struct BookmarksView: View {
|
|||||||
@State private var showingAddBookmarkFromShare = false
|
@State private var showingAddBookmarkFromShare = false
|
||||||
@State private var shareURL = ""
|
@State private var shareURL = ""
|
||||||
@State private var shareTitle = ""
|
@State private var shareTitle = ""
|
||||||
@State private var bookmarkToDelete: Bookmark? = nil
|
|
||||||
|
|
||||||
let state: BookmarkState
|
let state: BookmarkState
|
||||||
let type: [BookmarkType]
|
let type: [BookmarkType]
|
||||||
@ -39,14 +36,16 @@ struct BookmarksView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if shouldShowCenteredState {
|
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
|
||||||
|
skeletonLoadingView
|
||||||
|
} else if shouldShowCenteredState {
|
||||||
centeredStateView
|
centeredStateView
|
||||||
} else {
|
} else {
|
||||||
bookmarksList
|
bookmarksList
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAB Button - only show for "Unread" and when not in error/loading state
|
// FAB Button - only show for "Unread" and when not in error/loading state
|
||||||
if (state == .unread || state == .all) && !shouldShowCenteredState {
|
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
|
||||||
fabButton
|
fabButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -56,8 +55,7 @@ struct BookmarksView: View {
|
|||||||
set: { selectedBookmarkId = $0 }
|
set: { selectedBookmarkId = $0 }
|
||||||
)
|
)
|
||||||
) { bookmarkId in
|
) { bookmarkId in
|
||||||
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||||
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddBookmark) {
|
.sheet(isPresented: $showingAddBookmark) {
|
||||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||||
@ -68,18 +66,6 @@ struct BookmarksView: View {
|
|||||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.alert(item: $bookmarkToDelete) { bookmark in
|
|
||||||
Alert(
|
|
||||||
title: Text("Delete Bookmark"),
|
|
||||||
message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."),
|
|
||||||
primaryButton: .destructive(Text("Delete")) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteBookmark(bookmark: bookmark)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||||
@ -179,6 +165,11 @@ struct BookmarksView: View {
|
|||||||
List {
|
List {
|
||||||
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
// Don't navigate to detail if bookmark is pending deletion
|
||||||
|
if viewModel.pendingDeletes[bookmark.id] != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if UIDevice.isPhone {
|
if UIDevice.isPhone {
|
||||||
selectedBookmarkId = bookmark.id
|
selectedBookmarkId = bookmark.id
|
||||||
} else {
|
} else {
|
||||||
@ -195,20 +186,24 @@ struct BookmarksView: View {
|
|||||||
BookmarkCardView(
|
BookmarkCardView(
|
||||||
bookmark: bookmark,
|
bookmark: bookmark,
|
||||||
currentState: state,
|
currentState: state,
|
||||||
|
layout: viewModel.cardLayoutStyle,
|
||||||
|
pendingDelete: viewModel.pendingDeletes[bookmark.id],
|
||||||
onArchive: { bookmark in
|
onArchive: { bookmark in
|
||||||
Task {
|
Task {
|
||||||
await viewModel.toggleArchive(bookmark: bookmark)
|
await viewModel.toggleArchive(bookmark: bookmark)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete: { bookmark in
|
onDelete: { bookmark in
|
||||||
bookmarkToDelete = bookmark
|
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
|
||||||
},
|
},
|
||||||
onToggleFavorite: { bookmark in
|
onToggleFavorite: { bookmark in
|
||||||
Task {
|
Task {
|
||||||
await viewModel.toggleFavorite(bookmark: bookmark)
|
await viewModel.toggleFavorite(bookmark: bookmark)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
namespace: namespace
|
onUndoDelete: { bookmarkId in
|
||||||
|
viewModel.undoDelete(bookmarkId: bookmarkId)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
||||||
@ -219,10 +214,14 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.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)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||||
.matchedTransitionSource(id: bookmark.id, in: namespace)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading indicator for pagination
|
// Show loading indicator for pagination
|
||||||
@ -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
|
@ViewBuilder
|
||||||
private var fabButton: some View {
|
private var fabButton: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
|||||||
@ -7,21 +7,27 @@ class BookmarksViewModel {
|
|||||||
private let getBooksmarksUseCase: PGetBookmarksUseCase
|
private let getBooksmarksUseCase: PGetBookmarksUseCase
|
||||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||||
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
||||||
|
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||||
|
|
||||||
var bookmarks: BookmarksPage?
|
var bookmarks: BookmarksPage?
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
|
var isInitialLoading = true
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var currentState: BookmarkState = .unread
|
var currentState: BookmarkState = .unread
|
||||||
var currentType = [BookmarkType.article]
|
var currentType = [BookmarkType.article]
|
||||||
var currentTag: String? = nil
|
var currentTag: String? = nil
|
||||||
|
var cardLayoutStyle: CardLayoutStyle = .magazine
|
||||||
|
|
||||||
var showingAddBookmarkFromShare = false
|
var showingAddBookmarkFromShare = false
|
||||||
var shareURL = ""
|
var shareURL = ""
|
||||||
var shareTitle = ""
|
var shareTitle = ""
|
||||||
|
|
||||||
|
// Undo delete functionality
|
||||||
|
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
||||||
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var limit = 20
|
private var limit = 50
|
||||||
private var offset = 0
|
private var offset = 0
|
||||||
private var hasMoreData = true
|
private var hasMoreData = true
|
||||||
private var searchWorkItem: DispatchWorkItem?
|
private var searchWorkItem: DispatchWorkItem?
|
||||||
@ -36,13 +42,31 @@ class BookmarksViewModel {
|
|||||||
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
|
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
|
||||||
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||||
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
|
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
|
||||||
|
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||||
|
|
||||||
setupNotificationObserver()
|
setupNotificationObserver()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await loadCardLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObserver() {
|
private func setupNotificationObserver() {
|
||||||
|
// Listen for card layout changes
|
||||||
NotificationCenter.default
|
NotificationCenter.default
|
||||||
.publisher(for: 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
|
.sink { [weak self] notification in
|
||||||
self?.handleShareNotification(notification)
|
self?.handleShareNotification(notification)
|
||||||
}
|
}
|
||||||
@ -105,6 +129,7 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
isInitialLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -168,14 +193,97 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@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 {
|
do {
|
||||||
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
||||||
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
|
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error deleting bookmark"
|
// If delete fails, restore the bookmark
|
||||||
await loadBookmarks(state: currentState)
|
await MainActor.run {
|
||||||
|
errorMessage = "Error deleting bookmark"
|
||||||
|
if var currentBookmarks = bookmarks?.bookmarks {
|
||||||
|
currentBookmarks.insert(bookmark, at: 0)
|
||||||
|
bookmarks?.bookmarks = currentBookmarks
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadCardLayout() async {
|
||||||
|
cardLayoutStyle = await loadCardLayoutUseCase.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PendingDelete: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let bookmark: Bookmark
|
||||||
|
var progress: Double = 0.0
|
||||||
|
var timer: Timer?
|
||||||
|
var deleteTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(bookmark: Bookmark) {
|
||||||
|
self.bookmark = bookmark
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
import Foundation
|
||||||
|
|
||||||
struct Constants {
|
struct Constants {
|
||||||
struct Labels {
|
// Empty for now - can be used for other constants in the future
|
||||||
static let pageSize = 12
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 8
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let result = FlowResult(
|
||||||
|
in: proposal.replacingUnspecifiedDimensions().width,
|
||||||
|
subviews: subviews,
|
||||||
|
spacing: spacing
|
||||||
|
)
|
||||||
|
return result.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
let result = FlowResult(
|
||||||
|
in: bounds.width,
|
||||||
|
subviews: subviews,
|
||||||
|
spacing: spacing
|
||||||
|
)
|
||||||
|
|
||||||
|
for (index, subview) in subviews.enumerated() {
|
||||||
|
subview.place(at: CGPoint(
|
||||||
|
x: bounds.minX + result.frames[index].minX,
|
||||||
|
y: bounds.minY + result.frames[index].minY
|
||||||
|
), proposal: ProposedViewSize(result.frames[index].size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FlowResult {
|
||||||
|
var frames: [CGRect] = []
|
||||||
|
var bounds: CGSize = .zero
|
||||||
|
|
||||||
|
init(in maxWidth: CGFloat, subviews: LayoutSubviews, spacing: CGFloat) {
|
||||||
|
var x: CGFloat = 0
|
||||||
|
var y: CGFloat = 0
|
||||||
|
var lineHeight: CGFloat = 0
|
||||||
|
|
||||||
|
for subview in subviews {
|
||||||
|
let size = subview.sizeThatFits(.unspecified)
|
||||||
|
|
||||||
|
if x + size.width > maxWidth && x > 0 {
|
||||||
|
x = 0
|
||||||
|
y += lineHeight + spacing
|
||||||
|
lineHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
frames.append(CGRect(x: x, y: y, width: size.width, height: size.height))
|
||||||
|
lineHeight = max(lineHeight, size.height)
|
||||||
|
x += size.width + spacing
|
||||||
|
bounds.width = max(bounds.width, x - spacing)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds.height = y + lineHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum AddBookmarkFieldFocus {
|
enum AddBookmarkFieldFocus {
|
||||||
case url
|
case url
|
||||||
case labels
|
case labels
|
||||||
@ -27,7 +83,6 @@ struct TagManagementView: View {
|
|||||||
let selectedLabelsSet: Set<String>
|
let selectedLabelsSet: Set<String>
|
||||||
let searchText: Binding<String>
|
let searchText: Binding<String>
|
||||||
let isLabelsLoading: Bool
|
let isLabelsLoading: Bool
|
||||||
let availableLabelPages: [[BookmarkLabel]]
|
|
||||||
let filteredLabels: [BookmarkLabel]
|
let filteredLabels: [BookmarkLabel]
|
||||||
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
||||||
|
|
||||||
@ -44,7 +99,6 @@ struct TagManagementView: View {
|
|||||||
selectedLabels: Set<String>,
|
selectedLabels: Set<String>,
|
||||||
searchText: Binding<String>,
|
searchText: Binding<String>,
|
||||||
isLabelsLoading: Bool,
|
isLabelsLoading: Bool,
|
||||||
availableLabelPages: [[BookmarkLabel]],
|
|
||||||
filteredLabels: [BookmarkLabel],
|
filteredLabels: [BookmarkLabel],
|
||||||
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
||||||
onAddCustomTag: @escaping () -> Void,
|
onAddCustomTag: @escaping () -> Void,
|
||||||
@ -55,7 +109,6 @@ struct TagManagementView: View {
|
|||||||
self.selectedLabelsSet = selectedLabels
|
self.selectedLabelsSet = selectedLabels
|
||||||
self.searchText = searchText
|
self.searchText = searchText
|
||||||
self.isLabelsLoading = isLabelsLoading
|
self.isLabelsLoading = isLabelsLoading
|
||||||
self.availableLabelPages = availableLabelPages
|
|
||||||
self.filteredLabels = filteredLabels
|
self.filteredLabels = filteredLabels
|
||||||
self.searchFieldFocus = searchFieldFocus
|
self.searchFieldFocus = searchFieldFocus
|
||||||
self.onAddCustomTag = onAddCustomTag
|
self.onAddCustomTag = onAddCustomTag
|
||||||
@ -138,7 +191,7 @@ struct TagManagementView: View {
|
|||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
} else if availableLabelPages.isEmpty {
|
} else if allLabels.isEmpty {
|
||||||
VStack {
|
VStack {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 24))
|
.font(.system(size: 24))
|
||||||
@ -150,7 +203,7 @@ struct TagManagementView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
} else {
|
} else {
|
||||||
labelsTabView
|
labelsScrollView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@ -158,28 +211,47 @@ struct TagManagementView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var labelsTabView: some View {
|
private var labelsScrollView: some View {
|
||||||
TabView {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
||||||
ForEach(labelsPage, id: \.id) { label in
|
HStack(alignment: .top, spacing: 8) {
|
||||||
UnifiedLabelChip(
|
ForEach(rowLabels, id: \.id) { label in
|
||||||
label: label.name,
|
UnifiedLabelChip(
|
||||||
isSelected: selectedLabelsSet.contains(label.name),
|
label: label.name,
|
||||||
isRemovable: false,
|
isSelected: false,
|
||||||
onTap: {
|
isRemovable: false,
|
||||||
onToggleLabel(label.name)
|
onTap: {
|
||||||
}
|
onToggleLabel(label.name)
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
|
.frame(height: calculateMaxHeight())
|
||||||
.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
|
@ViewBuilder
|
||||||
@ -190,11 +262,11 @@ struct TagManagementView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.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
|
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
|
||||||
UnifiedLabelChip(
|
UnifiedLabelChip(
|
||||||
label: label,
|
label: label,
|
||||||
isSelected: false,
|
isSelected: true,
|
||||||
isRemovable: true,
|
isRemovable: true,
|
||||||
onTap: {
|
onTap: {
|
||||||
// No action for selected labels
|
// 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 makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||||
|
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||||
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -102,4 +104,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
|
||||||
return OfflineBookmarkSyncUseCase()
|
return OfflineBookmarkSyncUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
|
||||||
|
return LoadCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||||
|
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,13 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
MockAddTextToSpeechQueueUseCase()
|
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 {
|
extension Bookmark {
|
||||||
static let mock: Bookmark = .init(
|
static let mock: Bookmark = .init(
|
||||||
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
||||||
|
|||||||
@ -18,12 +18,14 @@ struct PhoneTabView: View {
|
|||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GlobalPlayerContainerView {
|
NavigationStack {
|
||||||
TabView(selection: $selectedTabIndex) {
|
GlobalPlayerContainerView {
|
||||||
mainTabsContent
|
TabView(selection: $selectedTabIndex) {
|
||||||
moreTabContent
|
mainTabsContent
|
||||||
|
moreTabContent
|
||||||
|
}
|
||||||
|
.accentColor(.accentColor)
|
||||||
}
|
}
|
||||||
.accentColor(.accentColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,23 +36,19 @@ struct PhoneTabView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var mainTabsContent: some View {
|
private var mainTabsContent: some View {
|
||||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||||
NavigationStack {
|
tabView(for: tab)
|
||||||
tabView(for: tab)
|
.tabItem {
|
||||||
}
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
.tabItem {
|
}
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
.tag(idx)
|
||||||
}
|
|
||||||
.tag(idx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var moreTabContent: some View {
|
private var moreTabContent: some View {
|
||||||
NavigationStack {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
moreTabsList
|
||||||
moreTabsList
|
moreTabsFooter
|
||||||
moreTabsFooter
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("More", systemImage: "ellipsis")
|
Label("More", systemImage: "ellipsis")
|
||||||
@ -71,6 +69,7 @@ struct PhoneTabView: View {
|
|||||||
NavigationLink {
|
NavigationLink {
|
||||||
tabView(for: tab)
|
tabView(for: tab)
|
||||||
.navigationTitle(tab.label)
|
.navigationTitle(tab.label)
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// tags and search handle navigation by own
|
// tags and search handle navigation by own
|
||||||
if tab != .tags && tab != .search {
|
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())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
@ -91,8 +91,7 @@ struct SearchBookmarksView: View {
|
|||||||
set: { selectedBookmarkId = $0 }
|
set: { selectedBookmarkId = $0 }
|
||||||
)
|
)
|
||||||
) { bookmarkId in
|
) { bookmarkId in
|
||||||
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||||
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if isFirstAppearance {
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(spacing: 20) {
|
||||||
// Header
|
SectionHeader(title: "Font Settings", icon: "textformat")
|
||||||
HStack(spacing: 8) {
|
.padding(.bottom, 4)
|
||||||
Image(systemName: "textformat")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
|
|
||||||
Text("Font")
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font Family Picker
|
// Font Family Picker
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||||
|
|||||||
@ -21,6 +21,12 @@ struct SettingsContainerView: View {
|
|||||||
FontSettingsView()
|
FontSettingsView()
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
|
|
||||||
|
AppearanceSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
|
CacheSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
SettingsGeneralView()
|
SettingsGeneralView()
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
|
|
||||||
@ -103,9 +109,19 @@ struct SettingsContainerView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "person.crop.circle")
|
Image(systemName: "person.crop.circle")
|
||||||
.foregroundColor(.secondary)
|
.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)
|
.font(.footnote)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.blue)
|
||||||
|
.underline()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
|
|||||||
@ -19,23 +19,6 @@ struct SettingsGeneralView: View {
|
|||||||
SectionHeader(title: "General Settings", icon: "gear")
|
SectionHeader(title: "General Settings", icon: "gear")
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
// Theme
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Theme")
|
|
||||||
.font(.headline)
|
|
||||||
Picker("Theme", selection: $viewModel.selectedTheme) {
|
|
||||||
ForEach(Theme.allCases, id: \.self) { theme in
|
|
||||||
Text(theme.displayName).tag(theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.onChange(of: viewModel.selectedTheme) {
|
|
||||||
Task {
|
|
||||||
await viewModel.saveGeneralSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("General")
|
Text("General")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@ -78,38 +61,6 @@ struct SettingsGeneralView: View {
|
|||||||
.toggleStyle(SwitchToggleStyle())
|
.toggleStyle(SwitchToggleStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data Management
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Data Management")
|
|
||||||
.font(.headline)
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
// await viewModel.clearCache()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text("Clear cache")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
// await viewModel.resetSettings()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text("Reset settings")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
if let successMessage = viewModel.successMessage {
|
if let successMessage = viewModel.successMessage {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@ -52,7 +52,7 @@ class SettingsGeneralViewModel {
|
|||||||
successMessage = "Settings saved"
|
successMessage = "Settings saved"
|
||||||
|
|
||||||
// send notification to apply settings to the app
|
// send notification to apply settings to the app
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SettingsChanged"), object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error saving settings"
|
errorMessage = "Error saving settings"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,16 +141,18 @@ struct SettingsServerView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
showingLogoutAlert = true
|
showingLogoutAlert = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||||
|
.font(.caption)
|
||||||
Text("Logout")
|
Text("Logout")
|
||||||
.fontWeight(.semibold)
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.padding(.horizontal, 16)
|
||||||
.padding()
|
.padding(.vertical, 8)
|
||||||
.background(Color.red)
|
.background(Color(.systemGray5))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.secondary)
|
||||||
.cornerRadius(10)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,7 @@ class SettingsServerViewModel {
|
|||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
successMessage = "Server settings saved and successfully logged in."
|
successMessage = "Server settings saved and successfully logged in."
|
||||||
try await SettingsRepository().saveHasFinishedSetup(true)
|
try await SettingsRepository().saveHasFinishedSetup(true)
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Connection or login failed: \(error.localizedDescription)"
|
errorMessage = "Connection or login failed: \(error.localizedDescription)"
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
@ -80,7 +80,7 @@ class SettingsServerViewModel {
|
|||||||
try await logoutUseCase.execute()
|
try await logoutUseCase.execute()
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
successMessage = "Logged out"
|
successMessage = "Logged out"
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error logging out"
|
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
|
@main
|
||||||
struct readeckApp: App {
|
struct readeckApp: App {
|
||||||
@State private var hasFinishedSetup = true
|
@StateObject private var appViewModel = AppViewModel()
|
||||||
@StateObject private var appSettings = AppSettings()
|
@StateObject private var appSettings = AppSettings()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
Group {
|
||||||
if hasFinishedSetup {
|
if appViewModel.hasFinishedSetup {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
} else {
|
} else {
|
||||||
SettingsServerView()
|
SettingsServerView()
|
||||||
@ -32,25 +32,19 @@ struct readeckApp: App {
|
|||||||
// Initialize server connectivity monitoring
|
// Initialize server connectivity monitoring
|
||||||
_ = ServerConnectivity.shared
|
_ = ServerConnectivity.shared
|
||||||
Task {
|
Task {
|
||||||
await loadSetupStatus()
|
await loadAppSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
||||||
Task {
|
Task {
|
||||||
await loadSetupStatus()
|
await loadAppSettings()
|
||||||
}
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in
|
|
||||||
Task {
|
|
||||||
await loadSetupStatus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSetupStatus() async {
|
private func loadAppSettings() async {
|
||||||
let settingsRepository = SettingsRepository()
|
let settingsRepository = SettingsRepository()
|
||||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
|
||||||
let settings = try? await settingsRepository.loadSettings()
|
let settings = try? await settingsRepository.loadSettings()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
appSettings.settings = settings
|
appSettings.settings = settings
|
||||||
|
|||||||
@ -51,6 +51,7 @@
|
|||||||
<attribute name="src" optional="YES" attributeType="String"/>
|
<attribute name="src" optional="YES" attributeType="String"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="cardLayoutStyle" optional="YES" attributeType="String"/>
|
||||||
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="fontFamily" optional="YES" attributeType="String"/>
|
<attribute name="fontFamily" optional="YES" attributeType="String"/>
|
||||||
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user