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 ## 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 - Share Extension for adding URLs from Safari and other apps
- Swipe actions for quick bookmark management - Swipe actions for quick bookmark management
- Native iOS design with Dark Mode support - Native iOS design with Dark Mode support
- Full iPad Support with Multi-Column Split View
## Requirements - Font Customization
- Article View with Reading Time and Word Count
- iOS 17.0+ - Search functionality
- Xcode 15.0+
- Swift 5.9+
## Configuration ## Configuration
After installing the app: After installing the app:
1. Open the readeck app 1. Open the readeck app
2. Go to the **Settings** tab 2. Enter your readeck server URL and credentials
3. Enter your readeck server URL and credentials 3. The app will automatically load your bookmarks
4. The app will automatically sync your bookmarks
## Architecture
- **SwiftUI** for UI
- **Core Data** for local storage
- **MVVM** architecture pattern
- **Repository pattern** for data access
## Share Extension ## 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 1. Share any webpage in Safari
2. Select "readeck" from the share sheet 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 ## 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`) 4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request 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 // URL Container View
let urlContainerView = UIView() let urlContainerView = UIView()
urlContainerView.translatesAutoresizingMaskIntoConstraints = false urlContainerView.translatesAutoresizingMaskIntoConstraints = false
urlContainerView.backgroundColor = UIColor.white urlContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
urlContainerView.layer.cornerRadius = 12 urlContainerView.layer.cornerRadius = 12
urlContainerView.layer.shadowColor = UIColor.black.cgColor urlContainerView.layer.shadowColor = UIColor.black.cgColor
urlContainerView.layer.shadowOffset = CGSize(width: 0, height: 2) urlContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
@ -86,7 +86,7 @@ class ShareViewController: UIViewController {
// Title Container View // Title Container View
let titleContainerView = UIView() let titleContainerView = UIView()
titleContainerView.translatesAutoresizingMaskIntoConstraints = false titleContainerView.translatesAutoresizingMaskIntoConstraints = false
titleContainerView.backgroundColor = UIColor.white titleContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
titleContainerView.layer.cornerRadius = 12 titleContainerView.layer.cornerRadius = 12
titleContainerView.layer.shadowColor = UIColor.black.cgColor titleContainerView.layer.shadowColor = UIColor.black.cgColor
titleContainerView.layer.shadowOffset = CGSize(width: 0, height: 2) titleContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
@ -97,11 +97,10 @@ class ShareViewController: UIViewController {
// Title TextField // Title TextField
titleTextField = UITextField() titleTextField = UITextField()
titleTextField?.translatesAutoresizingMaskIntoConstraints = false titleTextField?.translatesAutoresizingMaskIntoConstraints = false
titleTextField?.placeholder = "Titel eingeben..." titleTextField?.placeholder = "Optionales Titel eingeben..."
titleTextField?.borderStyle = .none titleTextField?.borderStyle = .none
titleTextField?.font = UIFont.systemFont(ofSize: 16) titleTextField?.font = UIFont.systemFont(ofSize: 16)
titleTextField?.backgroundColor = UIColor.clear titleTextField?.backgroundColor = UIColor.clear
titleTextField?.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
titleContainerView.addSubview(titleTextField!) titleContainerView.addSubview(titleTextField!)
// Status Label // Status Label
@ -120,7 +119,7 @@ class ShareViewController: UIViewController {
saveButton?.translatesAutoresizingMaskIntoConstraints = false saveButton?.translatesAutoresizingMaskIntoConstraints = false
saveButton?.setTitle("Bookmark speichern", for: .normal) saveButton?.setTitle("Bookmark speichern", for: .normal)
saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold) 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?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal)
saveButton?.layer.cornerRadius = 16 saveButton?.layer.cornerRadius = 16
saveButton?.layer.shadowColor = UIColor.black.cgColor saveButton?.layer.shadowColor = UIColor.black.cgColor
@ -128,11 +127,8 @@ class ShareViewController: UIViewController {
saveButton?.layer.shadowRadius = 8 saveButton?.layer.shadowRadius = 8
saveButton?.layer.shadowOpacity = 0.2 saveButton?.layer.shadowOpacity = 0.2
saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside) saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
saveButton?.isEnabled = false
saveButton?.alpha = 0.6
view.addSubview(saveButton!) view.addSubview(saveButton!)
// Activity Indicator // Activity Indicator
activityIndicator = UIActivityIndicatorView(style: .medium) activityIndicator = UIActivityIndicatorView(style: .medium)
@ -223,7 +219,6 @@ class ShareViewController: UIViewController {
if let url = url as? URL { if let url = url as? URL {
self?.extractedURL = url.absoluteString self?.extractedURL = url.absoluteString
self?.urlLabel?.text = 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) { if let text = text as? String, let url = URL(string: text) {
self?.extractedURL = url.absoluteString self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString self?.urlLabel?.text = url.absoluteString
self?.updateSaveButtonState()
} }
} }
} }
@ -245,15 +239,9 @@ class ShareViewController: UIViewController {
} }
// MARK: - Actions // MARK: - Actions
@objc private func textFieldDidChange() {
updateSaveButtonState()
}
@objc private func saveButtonTapped() { @objc private func saveButtonTapped() {
guard let title = titleTextField?.text, !title.isEmpty else { let title = titleTextField?.text ?? ""
showStatus("Bitte geben Sie einen Titel ein.", error: true)
return
}
saveButton?.isEnabled = false saveButton?.isEnabled = false
activityIndicator?.startAnimating() activityIndicator?.startAnimating()
@ -271,12 +259,6 @@ class ShareViewController: UIViewController {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil) 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 // MARK: - API Call
private func addBookmarkViaAPI(title: String) async { private func addBookmarkViaAPI(title: String) async {
guard let url = extractedURL, !url.isEmpty else { guard let url = extractedURL, !url.isEmpty else {
@ -346,7 +328,7 @@ class ShareViewController: UIViewController {
if !error { if !error {
// Automatically dismiss after success // Automatically dismiss after success
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} }
} }

View File

@ -9,6 +9,10 @@
/* 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 */; };
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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy 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; }; 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 */
@ -137,6 +142,8 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */, 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -160,6 +167,7 @@
5D45F9BF2DF858680048D5B8 = { 5D45F9BF2DF858680048D5B8 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
5D45F9CA2DF858680048D5B8 /* readeck */, 5D45F9CA2DF858680048D5B8 /* readeck */,
5D45F9E12DF8586A0048D5B8 /* readeckTests */, 5D45F9E12DF8586A0048D5B8 /* readeckTests */,
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */, 5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
@ -217,6 +225,7 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */,
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */, 5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
@ -225,6 +234,8 @@
name = readeck; name = readeck;
packageProductDependencies = ( packageProductDependencies = (
5D348CC22E0C9F4F00D0AF21 /* netfox */, 5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5DA241FC2E17C3B3007531C3 /* rswift */,
); );
productName = readeck; productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */; productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -308,11 +319,14 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
"fr-CA",
de,
); );
mainGroup = 5D45F9BF2DF858680048D5B8; mainGroup = 5D45F9BF2DF858680048D5B8;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */, 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */; productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -332,6 +346,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -339,6 +354,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -405,6 +421,10 @@
target = 5D45F9C72DF858680048D5B8 /* readeck */; target = 5D45F9C72DF858680048D5B8 /* readeck */;
targetProxy = 5D45F9E92DF8586A0048D5B8 /* PBXContainerItemProxy */; targetProxy = 5D45F9E92DF8586A0048D5B8 /* PBXContainerItemProxy */;
}; };
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@ -524,6 +544,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
name = Debug; name = Debug;
@ -578,6 +599,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
}; };
name = Release; name = Release;
}; };
@ -596,7 +618,6 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist; INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -639,7 +660,6 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist; INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -816,6 +836,14 @@
minimumVersion = 1.21.0; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -824,6 +852,21 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */; package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = 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 */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 5D45F9C02DF858680048D5B8 /* Project object */; rootObject = 5D45F9C02DF858680048D5B8 /* Project object */;

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "7374154e7686de69a9f88fbafb081b646b02140f8d82770f46fa750840581e0e", "originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
"pins" : [ "pins" : [
{ {
"identity" : "netfox", "identity" : "netfox",
@ -9,6 +9,33 @@
"revision" : "557576032736fd3140422baefb68b8f76c55088f", "revision" : "557576032736fd3140422baefb68b8f76c55088f",
"version" : "1.21.0" "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 "version" : 3

View File

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

View File

@ -89,41 +89,3 @@ class BookmarksRepository: PBookmarksRepository {
return bookmarkDtos.toDomain() 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) Divider().padding(.horizontal)
contentSection contentSection
Spacer(minLength: 40) Spacer(minLength: 40)
archiveSection
} }
} }
} }
@ -190,6 +191,39 @@ struct BookmarkDetailView: View {
} }
return dateString 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 { #Preview {

View File

@ -5,6 +5,7 @@ class BookmarkDetailViewModel {
private let getBookmarkUseCase: GetBookmarkUseCase private let getBookmarkUseCase: GetBookmarkUseCase
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
private let loadSettingsUseCase: LoadSettingsUseCase private let loadSettingsUseCase: LoadSettingsUseCase
private let updateBookmarkUseCase: UpdateBookmarkUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = "" var articleContent: String = ""
@ -20,6 +21,7 @@ class BookmarkDetailViewModel {
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase() self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
} }
@MainActor @MainActor
@ -30,11 +32,6 @@ class BookmarkDetailViewModel {
do { do {
settings = try await loadSettingsUseCase.execute() settings = try await loadSettingsUseCase.execute()
bookmarkDetail = try await getBookmarkUseCase.execute(id: id) 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 { } catch {
errorMessage = "Fehler beim Laden des Bookmarks" errorMessage = "Fehler beim Laden des Bookmarks"
} }
@ -57,31 +54,23 @@ class BookmarkDetailViewModel {
} }
private func processArticleContent() { private func processArticleContent() {
// HTML in Paragraphen aufteilen
let paragraphs = articleContent let paragraphs = articleContent
.components(separatedBy: .newlines) .components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
articleParagraphs = paragraphs articleParagraphs = paragraphs
} }
}
@MainActor
extension BookmarkDetail { func archiveBookmark(id: String) async {
static let empty = BookmarkDetail( isLoading = true
id: "", errorMessage = nil
title: "", do {
url: "", try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true)
description: "", bookmarkDetail.isArchived = true
siteName: "", } catch {
authors: [], errorMessage = "Fehler beim Archivieren des Bookmarks"
created: "", }
updated: "", isLoading = false
wordCount: 0, }
readingTime: 0,
hasArticle: false,
isMarked: false,
isArchived: false,
thumbnailUrl: "",
imageUrl: ""
)
} }

View File

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