feat: add German localization and improve share extension UX

- Add comprehensive German localization with Localizable.xcstrings
- Integrate R.swift for type-safe resource management
- Improve share extension UI with better styling and optional title input
- Add archive functionality to bookmark detail view
- Update README with current features and planned roadmap
- Remove title validation requirement from share extension
- Optimize share extension auto-dismiss timing
- Clean up code structure and remove unused components
This commit is contained in:
Ilyas Hallak 2025-07-04 22:30:01 +02:00
parent 1763dd6fa1
commit 624816d914
12 changed files with 505 additions and 133 deletions

290
Localizable.xcstrings Normal file
View File

@ -0,0 +1,290 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"%lld min" : {
},
"%lld Minuten" : {
},
"12 min • Today • example.com" : {
},
"Abbrechen" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Abbrechen"
}
}
}
},
"Abmelden" : {
},
"Add Item" : {
},
"Anmelden & speichern" : {
},
"Archivieren" : {
},
"Artikel automatisch als gelesen markieren" : {
},
"Automatischer Sync" : {
},
"Benutzername" : {
},
"Bookmark archivieren" : {
},
"Bookmark ist archiviert" : {
},
"Bookmark speichern" : {
},
"Cache leeren" : {
},
"Datenmanagement" : {
},
"Debug-Anmeldung" : {
},
"done" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fertig"
}
}
}
},
"Einfügen" : {
},
"Einstellungen" : {
},
"Einstellungen speichern" : {
},
"Einstellungen zurücksetzen" : {
},
"Entfernen" : {
},
"Entwickler: %@" : {
},
"Erfolgreich angemeldet" : {
},
"Erforderlich" : {
},
"Erneut anmelden & speichern" : {
},
"Es wurden noch keine Bookmarks in %@ gefunden." : {
},
"Externe Links in In-App Safari öffnen" : {
},
"Favorit" : {
},
"Fehler" : {
},
"Fertig" : {
},
"Fertig mit Lesen?" : {
},
"font_settings_title" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schrift-Einstellungen"
}
}
}
},
"Füge einen neuen Link zu deiner Sammlung hinzu" : {
},
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
},
"https://example.com" : {
},
"https://readeck.example.com" : {
},
"Ihr Benutzername" : {
},
"Ihr Passwort" : {
},
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
},
"Item at %@" : {
},
"Keine Bookmarks" : {
},
"Keine Bookmarks gefunden." : {
},
"Keine Ergebnisse" : {
},
"Labels" : {
},
"Lade %@..." : {
},
"Lade Artikel..." : {
},
"Leseeinstellungen" : {
},
"Löschen" : {
},
"Mehr" : {
},
"Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen." : {
},
"Neues Bookmark" : {
},
"OK" : {
},
"Optional: Eigener Titel" : {
},
"Passwort" : {
},
"readeck Bookmark Title" : {
},
"Safari Reader Modus" : {
},
"Schließen" : {
},
"Schrift" : {
},
"Schrift-Einstellungen" : {
},
"Schriftart" : {
},
"Schriftgröße" : {
},
"Select a bookmark" : {
},
"Select an item" : {
},
"Server-Endpunkt" : {
},
"Speichern..." : {
},
"Suchbegriff eingeben..." : {
},
"Suche" : {
},
"Suche..." : {
},
"Sync-Einstellungen" : {
},
"Sync-Intervall" : {
},
"Tags" : {
},
"Theme" : {
},
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." : {
},
"Titel" : {
},
"Über die App" : {
},
"URL" : {
},
"URL gefunden:" : {
},
"Version %@" : {
},
"Vorschau" : {
},
"Website" : {
},
"Wiederherstellen" : {
},
"Wird gespeichert..." : {
},
"z.B. arbeit, wichtig, später" : {
},
"Zwischenablage" : {
}
},
"version" : "1.0"
}

View File

@ -10,32 +10,23 @@ https://codeberg.org/readeck/readeck
## Features
- Browse and manage bookmarks (Unread, Favorites, Archive)
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
- Share Extension for adding URLs from Safari and other apps
- Swipe actions for quick bookmark management
- Native iOS design with Dark Mode support
## Requirements
- iOS 17.0+
- Xcode 15.0+
- Swift 5.9+
- Full iPad Support with Multi-Column Split View
- Font Customization
- Article View with Reading Time and Word Count
- Search functionality
## Configuration
After installing the app:
1. Open the readeck app
2. Go to the **Settings** tab
3. Enter your readeck server URL and credentials
4. The app will automatically sync your bookmarks
2. Enter your readeck server URL and credentials
3. The app will automatically load your bookmarks
## Architecture
- **SwiftUI** for UI
- **Core Data** for local storage
- **MVVM** architecture pattern
- **Repository pattern** for data access
## Share Extension
@ -43,7 +34,17 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
1. Share any webpage in Safari
2. Select "readeck" from the share sheet
3. The bookmark is automatically added to your collection
3. Enter a title if you want and hit save
4. The bookmark is automatically added to your collection
## Planned Features
- [ ] Add support for bookmark filtering and sorting options
- [ ] Add support for tags
- [ ] Offline sync with Core Data
- [ ] Add support for collection management
- [ ] Add offline sync capabilities
- [ ] Add support for custom themes
## Contributing
@ -53,17 +54,3 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## Planned Features
- [ ] Offline sync with Core Data
- [ ] Add support for tags
- [ ] Add support for bookmark filtering and sorting options
- [ ] Implement search functionality
- [ ] Add support for collection management
- [ ] Add support for multiple readeck servers
- [ ] Add offline sync capabilities
- [ ] Add support for custom themes
- [ ] Implement push notifications for new bookmarks
- [ ] Support for iPad multitasking
- [ ] Implement a dark mode toggle in settings
- [ ] Implement a tutorial for first-time users

View File

@ -65,7 +65,7 @@ class ShareViewController: UIViewController {
// URL Container View
let urlContainerView = UIView()
urlContainerView.translatesAutoresizingMaskIntoConstraints = false
urlContainerView.backgroundColor = UIColor.white
urlContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
urlContainerView.layer.cornerRadius = 12
urlContainerView.layer.shadowColor = UIColor.black.cgColor
urlContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
@ -86,7 +86,7 @@ class ShareViewController: UIViewController {
// Title Container View
let titleContainerView = UIView()
titleContainerView.translatesAutoresizingMaskIntoConstraints = false
titleContainerView.backgroundColor = UIColor.white
titleContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
titleContainerView.layer.cornerRadius = 12
titleContainerView.layer.shadowColor = UIColor.black.cgColor
titleContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
@ -97,11 +97,10 @@ class ShareViewController: UIViewController {
// Title TextField
titleTextField = UITextField()
titleTextField?.translatesAutoresizingMaskIntoConstraints = false
titleTextField?.placeholder = "Titel eingeben..."
titleTextField?.placeholder = "Optionales Titel eingeben..."
titleTextField?.borderStyle = .none
titleTextField?.font = UIFont.systemFont(ofSize: 16)
titleTextField?.backgroundColor = UIColor.clear
titleTextField?.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
titleContainerView.addSubview(titleTextField!)
// Status Label
@ -120,7 +119,7 @@ class ShareViewController: UIViewController {
saveButton?.translatesAutoresizingMaskIntoConstraints = false
saveButton?.setTitle("Bookmark speichern", for: .normal)
saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
saveButton?.backgroundColor = UIColor.white
saveButton?.backgroundColor = UIColor.secondarySystemGroupedBackground
saveButton?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal)
saveButton?.layer.cornerRadius = 16
saveButton?.layer.shadowColor = UIColor.black.cgColor
@ -128,11 +127,8 @@ class ShareViewController: UIViewController {
saveButton?.layer.shadowRadius = 8
saveButton?.layer.shadowOpacity = 0.2
saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
saveButton?.isEnabled = false
saveButton?.alpha = 0.6
view.addSubview(saveButton!)
// Activity Indicator
activityIndicator = UIActivityIndicatorView(style: .medium)
@ -223,7 +219,6 @@ class ShareViewController: UIViewController {
if let url = url as? URL {
self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString
self?.updateSaveButtonState()
}
}
}
@ -235,7 +230,6 @@ class ShareViewController: UIViewController {
if let text = text as? String, let url = URL(string: text) {
self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString
self?.updateSaveButtonState()
}
}
}
@ -245,15 +239,9 @@ class ShareViewController: UIViewController {
}
// MARK: - Actions
@objc private func textFieldDidChange() {
updateSaveButtonState()
}
@objc private func saveButtonTapped() {
guard let title = titleTextField?.text, !title.isEmpty else {
showStatus("Bitte geben Sie einen Titel ein.", error: true)
return
}
let title = titleTextField?.text ?? ""
saveButton?.isEnabled = false
activityIndicator?.startAnimating()
@ -271,12 +259,6 @@ class ShareViewController: UIViewController {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
private func updateSaveButtonState() {
let isValid = !(titleTextField?.text?.isEmpty ?? true) && extractedURL != nil
saveButton?.isEnabled = isValid
saveButton?.alpha = isValid ? 1.0 : 0.6
}
// MARK: - API Call
private func addBookmarkViaAPI(title: String) async {
guard let url = extractedURL, !url.isEmpty else {
@ -346,7 +328,7 @@ class ShareViewController: UIViewController {
if !error {
// Automatically dismiss after success
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}

View File

@ -9,6 +9,10 @@
/* Begin PBXBuildFile section */
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FC2E17C3B3007531C3 /* rswift */; };
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -64,6 +68,7 @@
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -137,6 +142,8 @@
buildActionMask = 2147483647;
files = (
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -160,6 +167,7 @@
5D45F9BF2DF858680048D5B8 = {
isa = PBXGroup;
children = (
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
5D45F9CA2DF858680048D5B8 /* readeck */,
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
@ -217,6 +225,7 @@
buildRules = (
);
dependencies = (
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */,
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
@ -225,6 +234,8 @@
name = readeck;
packageProductDependencies = (
5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5DA241FC2E17C3B3007531C3 /* rswift */,
);
productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -308,11 +319,14 @@
knownRegions = (
en,
Base,
"fr-CA",
de,
);
mainGroup = 5D45F9BF2DF858680048D5B8;
minimizedProjectReferenceProxies = 1;
packageReferences = (
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -332,6 +346,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -339,6 +354,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -405,6 +421,10 @@
target = 5D45F9C72DF858680048D5B8 /* readeck */;
targetProxy = 5D45F9E92DF8586A0048D5B8 /* PBXContainerItemProxy */;
};
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@ -524,6 +544,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
@ -578,6 +599,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
};
name = Release;
};
@ -596,7 +618,6 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -639,7 +660,6 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -816,6 +836,14 @@
minimumVersion = 1.21.0;
};
};
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 7.8.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -824,6 +852,21 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = netfox;
};
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = RswiftLibrary;
};
5DA241FC2E17C3B3007531C3 /* rswift */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = rswift;
};
5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = "plugin:RswiftGenerateInternalResources";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 5D45F9C02DF858680048D5B8 /* Project object */;

View File

@ -1,5 +1,5 @@
{
"originHash" : "7374154e7686de69a9f88fbafb081b646b02140f8d82770f46fa750840581e0e",
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
"pins" : [
{
"identity" : "netfox",
@ -9,6 +9,33 @@
"revision" : "557576032736fd3140422baefb68b8f76c55088f",
"version" : "1.21.0"
}
},
{
"identity" : "r.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mac-cain13/R.swift.git",
"state" : {
"revision" : "a9abc6b0afe0fc4a5a71e1d7d2872143dff2d2f1",
"version" : "7.8.0"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
"version" : "1.6.1"
}
},
{
"identity" : "xcodeedit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tomlokhorst/XcodeEdit",
"state" : {
"revision" : "0e550cdee72844b35431afc3a1e176042be6d0f0",
"version" : "2.13.0"
}
}
],
"version" : 3

View File

@ -158,28 +158,28 @@ class API: PAPI {
}
func login(endpoint: String, username: String, password: String) async throws -> UserDto {
guard let url = URL(string: endpoint + "/api/auth") else { throw APIError.invalidURL }
let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password)
let requestData = try JSONEncoder().encode(loginRequest)
guard let url = URL(string: endpoint + "/api/auth") else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = requestData
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
throw APIError.serverError(httpResponse.statusCode)
}
let userDto = try JSONDecoder().decode(UserDto.self, from: data)
// Token NICHT automatisch speichern, da Settings noch nicht existieren
return userDto
return try JSONDecoder().decode(UserDto.self, from: data)
}
// Angepasste getBookmarks-Methode mit Header-Auslesen
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPageDto {
var endpoint = "/api/bookmarks"
var queryItems: [URLQueryItem] = []

View File

@ -89,41 +89,3 @@ class BookmarksRepository: PBookmarksRepository {
return bookmarkDtos.toDomain()
}
}
struct BookmarkDetail {
let id: String
let title: String
let url: String
let description: String
let siteName: String
let authors: [String]
let created: String
let updated: String
let wordCount: Int?
let readingTime: Int?
let hasArticle: Bool
let isMarked: Bool
let isArchived: Bool
let thumbnailUrl: String
let imageUrl: String
}
enum CreateBookmarkError: Error, LocalizedError {
case invalidURL
case duplicateBookmark
case networkError
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Die eingegebene URL ist ungültig"
case .duplicateBookmark:
return "Dieser Bookmark existiert bereits"
case .networkError:
return "Netzwerkfehler beim Erstellen des Bookmarks"
case .serverError(let message):
return message // Verwende die Server-Nachricht direkt
}
}
}

View File

@ -0,0 +1,21 @@
import Foundation
enum CreateBookmarkError: Error, LocalizedError {
case invalidURL
case duplicateBookmark
case networkError
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Die eingegebene URL ist ungültig"
case .duplicateBookmark:
return "Dieser Bookmark existiert bereits"
case .networkError:
return "Netzwerkfehler beim Erstellen des Bookmarks"
case .serverError(let message):
return message
}
}
}

View File

@ -0,0 +1,39 @@
import Foundation
struct BookmarkDetail {
let id: String
let title: String
let url: String
let description: String
let siteName: String
let authors: [String]
let created: String
let updated: String
let wordCount: Int?
let readingTime: Int?
let hasArticle: Bool
let isMarked: Bool
var isArchived: Bool
let thumbnailUrl: String
let imageUrl: String
}
extension BookmarkDetail {
static let empty = BookmarkDetail(
id: "",
title: "",
url: "",
description: "",
siteName: "",
authors: [],
created: "",
updated: "",
wordCount: 0,
readingTime: 0,
hasArticle: false,
isMarked: false,
isArchived: false,
thumbnailUrl: "",
imageUrl: ""
)
}

View File

@ -20,6 +20,7 @@ struct BookmarkDetailView: View {
Divider().padding(.horizontal)
contentSection
Spacer(minLength: 40)
archiveSection
}
}
}
@ -190,6 +191,39 @@ struct BookmarkDetailView: View {
}
return dateString
}
private var archiveSection: some View {
VStack(spacing: 12) {
Text("Fertig mit Lesen?")
.font(.headline)
.padding(.top, 24)
if viewModel.bookmarkDetail.isArchived {
Label("Bookmark ist archiviert", systemImage: "archivebox.fill")
} else {
Button(action: {
Task {
await viewModel.archiveBookmark(id: bookmarkId)
}
}) {
HStack {
Image(systemName: "archivebox")
Text("Bookmark archivieren")
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.footnote)
}
}
.padding(.horizontal)
.padding(.bottom, 32)
}
}
#Preview {

View File

@ -5,6 +5,7 @@ class BookmarkDetailViewModel {
private let getBookmarkUseCase: GetBookmarkUseCase
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let updateBookmarkUseCase: UpdateBookmarkUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -20,6 +21,7 @@ class BookmarkDetailViewModel {
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
}
@MainActor
@ -30,11 +32,6 @@ class BookmarkDetailViewModel {
do {
settings = try await loadSettingsUseCase.execute()
bookmarkDetail = try await getBookmarkUseCase.execute(id: id)
// Auch das vollständige Bookmark für readProgress laden
// (Falls GetBookmarkUseCase nur BookmarkDetail zurückgibt)
// Du könntest einen separaten UseCase für das vollständige Bookmark erstellen
} catch {
errorMessage = "Fehler beim Laden des Bookmarks"
}
@ -57,31 +54,23 @@ class BookmarkDetailViewModel {
}
private func processArticleContent() {
// HTML in Paragraphen aufteilen
let paragraphs = articleContent
.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
articleParagraphs = paragraphs
}
}
extension BookmarkDetail {
static let empty = BookmarkDetail(
id: "",
title: "",
url: "",
description: "",
siteName: "",
authors: [],
created: "",
updated: "",
wordCount: 0,
readingTime: 0,
hasArticle: false,
isMarked: false,
isArchived: false,
thumbnailUrl: "",
imageUrl: ""
)
@MainActor
func archiveBookmark(id: String) async {
isLoading = true
errorMessage = nil
do {
try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true)
bookmarkDetail.isArchived = true
} catch {
errorMessage = "Fehler beim Archivieren des Bookmarks"
}
isLoading = false
}
}

View File

@ -10,14 +10,14 @@ struct BookmarkCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Vorschaubild - verwende image oder thumbnail
AsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
} placeholder: {
Image("placeholder")
Image(R.image.placeholder.name)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
@ -25,14 +25,12 @@ struct BookmarkCardView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
// Titel
Text(bookmark.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
// Meta-Info mit Datum
VStack(alignment: .leading, spacing: 4) {
HStack {