Compare commits

..

No commits in common. "a09cad5d7e2d09458ea019662a3c0795b4190ea8" and "e68959afcecedbca39aad853b5987c6abb41f34d" have entirely different histories.

111 changed files with 1433 additions and 4391 deletions

3
.gitignore vendored
View File

@ -61,5 +61,4 @@ fastlane/report.xml
fastlane/Preview.html fastlane/Preview.html
fastlane/screenshots/**/*.png fastlane/screenshots/**/*.png
fastlane/test_output fastlane/test_output
fastlane/.env.default
fastlane/AuthKey_JZJCQWW9N3.p8

View File

@ -1 +0,0 @@
3.3.0

View File

@ -1,25 +0,0 @@
# Changelog
All changes to this project will be documented in this file.
## 1.0.0
**Initial release:**
- 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
- Full iPad Support with Multi-Column Split View
- Font Customization
- Article View with Reading Time and Word Count
- Search functionality
- Support for tags
- Support for reading progress
## [Unreleased]
### Planned Features
- [ ] Add support for bookmark filtering and sorting options
- [ ] Offline sync with Core Data
- [ ] Add support for collection management
- [ ] Add offline sync capabilities
- [ ] Add support for custom themes

View File

@ -1,58 +0,0 @@
# Code of Conduct
This project follows the Contributor Covenant v2.1.
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at mooonki:matrix.org. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
1. Correction
2. Warning
3. Temporary Ban
4. Permanent Ban
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[homepage]: https://www.contributor-covenant.org

View File

@ -1,59 +0,0 @@
# Contributing to Readeck (iOS)
Welcome! Thank you for your interest in contributing to the iOS app for Readeck. There are many ways you can help, whether through code, testing, feedback, or documentation.
**Please make sure to read and follow our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.**
## How to Contribute
### 1. Report Issues
If you find a bug or experience a problem with the iOS app, please open an issue in the repository. Describe the issue as clearly as possible and include screenshots or crash logs if available.
### 2. Suggest Features
Have an idea for a new feature or improvement? Open a feature request issue and describe your idea. You are also welcome to discuss ideas in the forum or the Matrix chat (#readeck:matrix.org) or contact me directly: mooonki:matrix.org.
### 3. Contribute Code
Pull requests are welcome! Heres how to get started:
1. Fork the repository and clone it locally.
2. Set up your development environment (see below).
3. Create a new branch for your changes.
4. Write clean, well-documented code and follow the existing coding standards.
5. Test your changes (unit and UI tests).
6. Open a pull request and describe your changes.
### 4. Translations
Help translate the iOS app! If you want to add or improve a language, edit the `Localizable.xcstrings` file and submit a pull request.
### 5. Improve Documentation
Help improve the README.md, Contribute.md, or other documentation. Good documentation benefits everyone!
## Setting Up the Development Environment
1. Install Xcode (latest version recommended).
2. Clone the repository:
```
git clone https://codeberg.org/readeck/readeck-ios.git
```
3. Install dependencies (if any, e.g., Swift Packages).
4. Open the project in Xcode: `readeck.xcodeproj`
5. Build and run on a simulator or device.
## Community & Support
- Forum: [Readeck Forum](https://readeck.org/forum)
- Matrix: #readeck:matrix.org
- Issues: In the respective repository on Codeberg or Github
## Code of Conduct
Please follow the [Code of Conduct](https://readeck.org/en/contribute) and be respectful in all interactions.
---
Thank you for helping make Readeck better!

View File

@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane"

View File

@ -1,227 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1129.0)
aws-sdk-core (3.226.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.106.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.193.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.13.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.16.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
plist (3.7.2)
public_suffix (6.0.2)
rake (13.3.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.1)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.20.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-24
ruby
DEPENDENCIES
fastlane
BUNDLED WITH
2.6.9

View File

@ -3,12 +3,6 @@
"strings" : { "strings" : {
"" : { "" : {
},
"(%lld found)" : {
},
"%" : {
}, },
"%@ (%lld)" : { "%@ (%lld)" : {
"localizations" : { "localizations" : {
@ -23,13 +17,13 @@
"%lld" : { "%lld" : {
}, },
"%lld articles in the queue" : { "%lld Artikel in der Queue" : {
}, },
"%lld min" : { "%lld min" : {
}, },
"%lld minutes" : { "%lld Minuten" : {
}, },
"%lld." : { "%lld." : {
@ -48,13 +42,13 @@
"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." : { "Abbrechen" : {
}, },
"Add" : { "Abmelden" : {
}, },
"Add new tag:" : { "Aktuelle Labels" : {
}, },
"all" : { "all" : {
@ -68,88 +62,100 @@
} }
} }
}, },
"All tags selected" : { "Anmelden & speichern" : {
}, },
"Archive" : { "Archivieren" : {
}, },
"Archive bookmark" : { "Artikel automatisch als gelesen markieren" : {
}, },
"Are you sure you want to delete this bookmark? This action cannot be undone." : { "Artikel vorlesen" : {
}, },
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : { "Automatischer Sync" : {
}, },
"Automatic sync" : { "Benutzername" : {
}, },
"Automatically mark articles as read" : { "Bookmark archivieren" : {
}, },
"Available tags" : { "Bookmark ist archiviert" : {
}, },
"Cancel" : { "Bookmark speichern" : {
}, },
"Clear cache" : { "Cache leeren" : {
}, },
"Close" : { "Datenmanagement" : {
}, },
"Data Management" : { "Debug-Anmeldung" : {
}, },
"Delete" : { "Einfügen" : {
}, },
"Delete Bookmark" : { "Einstellungen" : {
}, },
"Developer: Ilyas Hallak" : { "Einstellungen speichern" : {
}, },
"Done" : { "Einstellungen zurücksetzen" : {
}, },
"Enter an optional title..." : { "Entfernen" : {
}, },
"Enter your Readeck server details to get started." : { "Entwickler: %@" : {
}, },
"Error" : { "Erfolgreich angemeldet" : {
}, },
"Error: %@" : { "Erforderlich" : {
}, },
"Favorite" : { "Erneut anmelden & speichern" : {
}, },
"Finished reading?" : { "Es wurden noch keine Bookmarks in %@ gefunden." : {
}, },
"Font" : { "Externe Links in In-App Safari öffnen" : {
}, },
"Font family" : { "Favorit" : {
}, },
"Font Settings" : { "Fehler" : {
}, },
"Font size" : { "Fehler: %@" : {
}, },
"From Bremen with 💚" : { "Fertig" : {
}, },
"General" : { "Fertig mit Lesen?" : {
},
"Fortschritt: %lld%%" : {
},
"Füge einen neuen Link zu deiner Sammlung hinzu" : {
},
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
},
"Geschwindigkeit" : {
}, },
"https://example.com" : { "https://example.com" : {
@ -158,7 +164,19 @@
"https://readeck.example.com" : { "https://readeck.example.com" : {
}, },
"Jump to last read position (%lld%%)" : { "Ihr Benutzername" : {
},
"Ihr Passwort" : {
},
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
},
"Keine Artikel in der Queue" : {
},
"Keine Bookmarks" : {
}, },
"Keine Bookmarks gefunden." : { "Keine Bookmarks gefunden." : {
@ -166,141 +184,93 @@
}, },
"Keine Ergebnisse" : { "Keine Ergebnisse" : {
},
"Keine Labels vorhanden" : {
}, },
"Key" : { "Key" : {
"extractionState" : "manual" "extractionState" : "manual"
}, },
"Loading %@" : { "Label eingeben..." : {
}, },
"Loading article..." : { "Labels" : {
}, },
"Login & Save" : { "Labels verwalten" : {
}, },
"Logout" : { "Lade %@..." : {
}, },
"Manage Labels" : { "Lade Artikel..." : {
}, },
"Mark as favorite" : { "Lese %lld/%lld: " : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Lese %1$lld/%2$lld: "
}
}
}
},
"Leseeinstellungen" : {
}, },
"More" : { "Löschen" : {
}, },
"New Bookmark" : { "Mehr" : {
}, },
"No articles in the queue" : { "Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen." : {
}, },
"No bookmarks" : { "Neues Bookmark" : {
}, },
"No bookmarks found in %@." : { "Neues Label hinzufügen" : {
}, },
"OK" : { "OK" : {
}, },
"Open external links in in-app Safari" : { "Optional: Eigener Titel" : {
}, },
"Optional: Custom title" : { "Passwort" : {
},
"Password" : {
},
"Paste" : {
},
"Please wait while we fetch your bookmarks..." : {
},
"Preview" : {
},
"Progress: %lld%%" : {
},
"Re-login & Save" : {
},
"Read Aloud Feature" : {
},
"Read article aloud" : {
},
"Read-aloud Queue" : {
}, },
"readeck Bookmark Title" : { "readeck Bookmark Title" : {
}, },
"Reading %lld/%lld: " : { "Safari Reader Modus" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Reading %1$lld/%2$lld: "
}
}
}
},
"Reading Settings" : {
}, },
"Remove" : { "Schließen" : {
}, },
"Reset settings" : { "Schrift" : {
}, },
"Restore" : { "Schrift-Einstellungen" : {
}, },
"Resume listening" : { "Schriftart" : {
}, },
"Safari Reader Mode" : { "Schriftgröße" : {
},
"Save bookmark" : {
},
"Save Bookmark" : {
},
"Saving..." : {
},
"Search or add new tag..." : {
},
"Search results" : {
}, },
"Select a bookmark or tag" : { "Select a bookmark or tag" : {
}, },
"Selected tags" : { "Server-Endpunkt" : {
}, },
"Server Endpoint" : { "Speichern..." : {
},
"Settings" : {
},
"Speed" : {
},
"Successfully logged in" : {
}, },
"Suchbegriff eingeben..." : { "Suchbegriff eingeben..." : {
@ -312,10 +282,13 @@
"Suche..." : { "Suche..." : {
}, },
"Sync interval" : { "Sync-Einstellungen" : {
}, },
"Sync Settings" : { "Sync-Intervall" : {
},
"Tags" : {
}, },
"Theme" : { "Theme" : {
@ -324,25 +297,43 @@
"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." : {
}, },
"Unarchive Bookmark" : { "Titel" : {
}, },
"URL in clipboard:" : { "Über die App" : {
}, },
"Username" : { "URL" : {
},
"URL gefunden:" : {
}, },
"Version %@" : { "Version %@" : {
}, },
"Your current server connection and login credentials." : { "Vorlese-Queue" : {
}, },
"Your Password" : { "Vorschau" : {
}, },
"Your Username" : { "Website" : {
},
"Weiterhören" : {
},
"Wiederherstellen" : {
},
"Wird gespeichert..." : {
},
"z.B. arbeit, wichtig, später" : {
},
"Zwischenablage" : {
} }
}, },

View File

@ -1,4 +1,4 @@
# Readeck iOS App # ReadKeep iOS App
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@ -8,23 +8,7 @@ A native iOS client for [readeck](https://readeck.org) bookmark management.
The official repository is on Codeberg: The official repository is on Codeberg:
https://codeberg.org/readeck/readeck https://codeberg.org/readeck/readeck
## TestFlight Beta Access ## Features
You can now join the public TestFlight beta for the Readeck iOS app:
[Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR)
To participate, simply install TestFlight from the App Store and open the link above on your iPhone, iPad, or Mac. This early version lets you explore all core features before the official release. Your feedback is incredibly valuable and will help shape the final app.
What to test:
- See the feature list below for an overview of what you can try out.
- For details and recent changes, please refer to the release notes in TestFlight or the [Changelog](./CHANGELOG.md).
Please report any bugs, crashes, or suggestions directly through TestFlight, or email me at ilhallak@gmail.com. Thank you for helping make Readeck better!
If you are interested in joining the internal beta, please contact me directly at mooonki:matrix.org.
## Core Features
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures) - 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
@ -34,7 +18,6 @@ If you are interested in joining the internal beta, please contact me directly a
- Font Customization - Font Customization
- Article View with Reading Time and Word Count - Article View with Reading Time and Word Count
- Search functionality - Search functionality
- Support for reading progress
## Configuration ## Configuration
@ -44,7 +27,6 @@ After installing the app:
2. Enter your readeck server URL and credentials 2. Enter your readeck server URL and credentials
3. The app will automatically load your bookmarks 3. The app will automatically load your bookmarks
Notice: Local Network Addresses are supported. If you use external Domains, you need to add a HTTPS Certificate to your readeck server. Apple does not allow to use HTTP on iOS for external domains in release versions. If you want to use HTTP, you are free to use the beta version of the app, where the HTTP is supported.
## Share Extension ## Share Extension
@ -55,9 +37,13 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
3. Enter a title if you want and hit save 3. Enter a title if you want and hit save
4. The bookmark is automatically added to your collection 4. The bookmark is automatically added to your collection
## Versions ## Planned Features
- [ ] Add support for bookmark filtering and sorting options
[see Changelog](./CHANGELOG.md) - [ ] 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

View File

@ -1,208 +0,0 @@
import SwiftUI
struct ShareBookmarkView: View {
@ObservedObject var viewModel: ShareBookmarkViewModel
@State private var keyboardHeight: CGFloat = 0
@State private var shouldScrollToTitle = false
private func dismissKeyboard() {
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
}
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
logoSection
urlSection
tagManagementSection
titleSection
.id("titleField")
statusSection
Spacer(minLength: 100) // Space for button
}
}
.padding(.bottom, keyboardHeight / 2)
.onChange(of: shouldScrollToTitle) { shouldScroll, _ in
if shouldScroll {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo("titleField", anchor: .center)
}
shouldScrollToTitle = false
}
}
}
saveButtonSection
}
.background(Color(.systemGroupedBackground))
.onAppear { viewModel.onAppear() }
.ignoresSafeArea(.keyboard, edges: .bottom)
.background(
Color.clear
.contentShape(Rectangle())
.onTapGesture {
// Fallback for extensions: tap anywhere to dismiss keyboard
dismissKeyboard()
}
)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardFrame.height
// Scroll to title field when keyboard appears
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
shouldScrollToTitle = true
}
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
keyboardHeight = 0
}
}
// MARK: - View Components
@ViewBuilder
private var logoSection: some View {
Image("readeck")
.resizable()
.scaledToFit()
.frame(height: 40)
.padding(.top, 24)
.opacity(0.9)
}
@ViewBuilder
private var urlSection: some View {
if let url = viewModel.url {
HStack(spacing: 8) {
Image(systemName: "link")
.foregroundColor(.accentColor)
Text(url)
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundColor(.accentColor)
.lineLimit(2)
.truncationMode(.middle)
}
.padding(.top, 8)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
private var titleSection: some View {
TextField("Enter an optional title...", text: $viewModel.title)
.textFieldStyle(CustomTextFieldStyle())
.font(.system(size: 17, weight: .medium))
.padding(.horizontal, 10)
.foregroundColor(.primary)
.frame(height: 38)
.padding(.top, 20)
.padding(.horizontal, 4)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, alignment: .center)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
dismissKeyboard()
}
}
}
}
@ViewBuilder
private var tagManagementSection: some View {
if !viewModel.labels.isEmpty {
TagManagementView(
allLabels: convertToBookmarkLabels(viewModel.labels),
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: false,
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
onAddCustomTag: {
addCustomTag()
},
onToggleLabel: { label in
if viewModel.selectedLabels.contains(label) {
viewModel.selectedLabels.remove(label)
} else {
viewModel.selectedLabels.insert(label)
}
viewModel.searchText = ""
},
onRemoveLabel: { label in
viewModel.selectedLabels.remove(label)
}
)
.padding(.top, 20)
.padding(.horizontal, 16)
}
}
@ViewBuilder
private var statusSection: some View {
if let status = viewModel.statusMessage {
Text(status.emoji + " " + status.text)
.font(.system(size: 18, weight: .bold))
.foregroundColor(status.isError ? .red : .green)
.padding(.top, 32)
.padding(.horizontal, 16)
}
}
@ViewBuilder
private var saveButtonSection: some View {
Button(action: { viewModel.save() }) {
if viewModel.isSaving {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.frame(maxWidth: .infinity)
.padding()
} else {
Text("Save Bookmark")
.font(.system(size: 17, weight: .semibold))
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(16)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 32)
.disabled(viewModel.isSaving)
.background(Color(.systemGroupedBackground))
}
// MARK: - Helper Functions
private func convertToBookmarkLabels(_ dtos: [BookmarkLabelDto]) -> [BookmarkLabel] {
return dtos.map { .init(name: $0.name, count: $0.count, href: $0.href) }
}
private func convertToBookmarkLabelPages(_ dtoPages: [[BookmarkLabelDto]]) -> [[BookmarkLabel]] {
return dtoPages.map { convertToBookmarkLabels($0) }
}
private func addCustomTag() {
let trimmed = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let lowercased = trimmed.lowercased()
let allExisting = Set(viewModel.labels.map { $0.name.lowercased() })
let allSelected = Set(viewModel.selectedLabels.map { $0.lowercased() })
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
// Tag already exists, don't add
return
} else {
viewModel.selectedLabels.insert(trimmed)
viewModel.searchText = ""
}
}
}

View File

@ -1,108 +0,0 @@
import Foundation
import SwiftUI
import UniformTypeIdentifiers
class ShareBookmarkViewModel: ObservableObject {
@Published var url: String?
@Published var title: String = ""
@Published var labels: [BookmarkLabelDto] = []
@Published var selectedLabels: Set<String> = []
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
@Published var isSaving: Bool = false
@Published var searchText: String = ""
let extensionContext: NSExtensionContext?
// Computed properties for pagination
var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) }
}
// Computed property for filtered labels based on search text
var filteredLabels: [BookmarkLabelDto] {
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var availableLabelPages: [[BookmarkLabelDto]] {
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)])
}
}
}
init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext
extractSharedContent()
}
func onAppear() {
loadLabels()
}
private func extractSharedContent() {
guard let extensionContext = extensionContext else { return }
for item in extensionContext.inputItems {
guard let inputItem = item as? NSExtensionItem else { continue }
for attachment in inputItem.attachments ?? [] {
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
DispatchQueue.main.async {
if let url = url as? URL {
self?.url = url.absoluteString
}
}
}
}
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in
DispatchQueue.main.async {
if let text = text as? String, let url = URL(string: text) {
self?.url = url.absoluteString
}
}
}
}
}
}
}
func loadLabels() {
Task {
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
self?.statusMessage = (message, error, error ? "" : "")
} ?? []
let sorted = loaded.sorted { $0.count > $1.count }
await MainActor.run {
self.labels = Array(sorted)
}
}
}
func save() {
guard let url = url, !url.isEmpty else {
statusMessage = ("No URL found.", true, "")
return
}
isSaving = true
Task {
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
self?.statusMessage = (message, error, error ? "" : "")
self?.isSaving = false
if !error {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
}
}
}

View File

@ -8,42 +8,349 @@
import UIKit import UIKit
import Social import Social
import UniformTypeIdentifiers import UniformTypeIdentifiers
import SwiftUI
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
private var hostingController: UIHostingController<ShareBookmarkView>? private var extractedURL: String?
private var extractedTitle: String?
// UI Elements
private var titleTextField: UITextField?
private var urlLabel: UILabel?
private var statusLabel: UILabel?
private var saveButton: UIButton?
private var activityIndicator: UIActivityIndicatorView?
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext) setupUI()
let swiftUIView = ShareBookmarkView(viewModel: viewModel) extractSharedContent()
let hostingController = UIHostingController(rootView: swiftUIView) }
addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view)
// MARK: - UI Setup
private func setupUI() {
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
// Add cancel button
let cancelButton = UIBarButtonItem(title: "Abbrechen", style: .plain, target: self, action: #selector(cancelButtonTapped))
cancelButton.tintColor = UIColor.white
navigationItem.leftBarButtonItem = cancelButton
// Ensure navigation bar is visible
navigationController?.navigationBar.isTranslucent = false
navigationController?.navigationBar.backgroundColor = UIColor(named: "green") ?? UIColor.systemGreen
navigationController?.navigationBar.tintColor = UIColor.white
navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
// Add logo
let logoImageView = UIImageView(image: UIImage(named: "readeck"))
logoImageView.translatesAutoresizingMaskIntoConstraints = false
logoImageView.contentMode = .scaleAspectFit
logoImageView.alpha = 0.9
view.addSubview(logoImageView)
// Add custom cancel button
let customCancelButton = UIButton(type: .system)
customCancelButton.translatesAutoresizingMaskIntoConstraints = false
customCancelButton.setTitle("Abbrechen", for: .normal)
customCancelButton.setTitleColor(UIColor.white, for: .normal)
customCancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
customCancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
view.addSubview(customCancelButton)
// URL Container View
let urlContainerView = UIView()
urlContainerView.translatesAutoresizingMaskIntoConstraints = false
urlContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
urlContainerView.layer.cornerRadius = 12
urlContainerView.layer.shadowColor = UIColor.black.cgColor
urlContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
urlContainerView.layer.shadowRadius = 4
urlContainerView.layer.shadowOpacity = 0.1
view.addSubview(urlContainerView)
// URL Label
urlLabel = UILabel()
urlLabel?.translatesAutoresizingMaskIntoConstraints = false
urlLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
urlLabel?.textColor = UIColor.label
urlLabel?.numberOfLines = 0
urlLabel?.text = "URL wird geladen..."
urlLabel?.textAlignment = .left
urlContainerView.addSubview(urlLabel!)
// Title Container View
let titleContainerView = UIView()
titleContainerView.translatesAutoresizingMaskIntoConstraints = false
titleContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
titleContainerView.layer.cornerRadius = 12
titleContainerView.layer.shadowColor = UIColor.black.cgColor
titleContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
titleContainerView.layer.shadowRadius = 4
titleContainerView.layer.shadowOpacity = 0.1
view.addSubview(titleContainerView)
// Title TextField
titleTextField = UITextField()
titleTextField?.translatesAutoresizingMaskIntoConstraints = false
titleTextField?.placeholder = "Optionales Titel eingeben..."
titleTextField?.borderStyle = .none
titleTextField?.font = UIFont.systemFont(ofSize: 16)
titleTextField?.backgroundColor = UIColor.clear
titleContainerView.addSubview(titleTextField!)
// Status Label
statusLabel = UILabel()
statusLabel?.translatesAutoresizingMaskIntoConstraints = false
statusLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
statusLabel?.numberOfLines = 0
statusLabel?.textAlignment = .center
statusLabel?.isHidden = true
statusLabel?.layer.cornerRadius = 10
statusLabel?.layer.masksToBounds = true
view.addSubview(statusLabel!)
// Save Button
saveButton = UIButton(type: .system)
saveButton?.translatesAutoresizingMaskIntoConstraints = false
saveButton?.setTitle("Bookmark speichern", for: .normal)
saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
saveButton?.backgroundColor = UIColor.secondarySystemGroupedBackground
saveButton?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal)
saveButton?.layer.cornerRadius = 16
saveButton?.layer.shadowColor = UIColor.black.cgColor
saveButton?.layer.shadowOffset = CGSize(width: 0, height: 4)
saveButton?.layer.shadowRadius = 8
saveButton?.layer.shadowOpacity = 0.2
saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
view.addSubview(saveButton!)
// Activity Indicator
activityIndicator = UIActivityIndicatorView(style: .medium)
activityIndicator?.translatesAutoresizingMaskIntoConstraints = false
activityIndicator?.hidesWhenStopped = true
view.addSubview(activityIndicator!)
setupConstraints()
}
private func setupConstraints() {
guard let urlLabel = urlLabel,
let titleTextField = titleTextField,
let statusLabel = statusLabel,
let saveButton = saveButton,
let activityIndicator = activityIndicator else { return }
// Find container views and logo
let urlContainerView = urlLabel.superview!
let titleContainerView = titleTextField.superview!
let logoImageView = view.subviews.first { $0 is UIImageView }!
let customCancelButton = view.subviews.first { $0 is UIButton && $0 != saveButton }!
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), // Custom Cancel Button
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), customCancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), customCancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) customCancelButton.heightAnchor.constraint(equalToConstant: 36),
customCancelButton.widthAnchor.constraint(equalToConstant: 100),
// Logo
logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
logoImageView.heightAnchor.constraint(equalToConstant: 40),
logoImageView.widthAnchor.constraint(equalToConstant: 120),
// URL Container
urlContainerView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 24),
urlContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
urlContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
// URL Label inside container
urlLabel.topAnchor.constraint(equalTo: urlContainerView.topAnchor, constant: 16),
urlLabel.leadingAnchor.constraint(equalTo: urlContainerView.leadingAnchor, constant: 16),
urlLabel.trailingAnchor.constraint(equalTo: urlContainerView.trailingAnchor, constant: -16),
urlLabel.bottomAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: -16),
// Title Container
titleContainerView.topAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: 20),
titleContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
titleContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
titleContainerView.heightAnchor.constraint(equalToConstant: 60),
// Title TextField inside container
titleTextField.topAnchor.constraint(equalTo: titleContainerView.topAnchor, constant: 16),
titleTextField.leadingAnchor.constraint(equalTo: titleContainerView.leadingAnchor, constant: 16),
titleTextField.trailingAnchor.constraint(equalTo: titleContainerView.trailingAnchor, constant: -16),
titleTextField.bottomAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: -16),
// Status Label
statusLabel.topAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: 20),
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
// Save Button
saveButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 32),
saveButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
saveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
saveButton.heightAnchor.constraint(equalToConstant: 56),
// Activity Indicator
activityIndicator.centerXAnchor.constraint(equalTo: saveButton.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: saveButton.centerYAnchor)
]) ])
hostingController.didMove(toParent: self)
self.hostingController = hostingController
NotificationCenter.default.addObserver(
self,
selector: #selector(dismissKeyboard),
name: NSNotification.Name("DismissKeyboard"),
object: nil
)
} }
@objc private func dismissKeyboard() { // MARK: - Content Extraction
self.view.endEditing(true) private func extractSharedContent() {
guard let extensionContext = extensionContext else { return }
for item in extensionContext.inputItems {
guard let inputItem = item as? NSExtensionItem else { continue }
for attachment in inputItem.attachments ?? [] {
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
DispatchQueue.main.async {
if let url = url as? URL {
self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString
}
}
}
}
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in
DispatchQueue.main.async {
if let text = text as? String, let url = URL(string: text) {
self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString
}
}
}
}
}
}
} }
deinit { // MARK: - Actions
NotificationCenter.default.removeObserver(self)
@objc private func saveButtonTapped() {
let title = titleTextField?.text ?? ""
saveButton?.isEnabled = false
activityIndicator?.startAnimating()
Task {
await addBookmarkViaAPI(title: title)
await MainActor.run {
self.saveButton?.isEnabled = true
self.activityIndicator?.stopAnimating()
}
}
}
@objc private func cancelButtonTapped() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
// MARK: - API Call
private func addBookmarkViaAPI(title: String) async {
guard let url = extractedURL, !url.isEmpty else {
showStatus("Keine URL gefunden.", error: true)
return
}
// Token und Endpoint aus KeychainHelper
guard let token = KeychainHelper.shared.loadToken() else {
showStatus("Kein Token gefunden. Bitte in der Haupt-App einloggen.", error: true)
return
}
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
showStatus("Kein Server-Endpunkt gefunden.", error: true)
return
}
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
guard let requestData = try? JSONEncoder().encode(requestDto) else {
showStatus("Fehler beim Kodieren der Anfrage.", error: true)
return
}
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
showStatus("Ungültiger Server-Endpunkt.", error: true)
return
}
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = requestData
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
showStatus("Ungültige Server-Antwort.", error: true)
return
}
guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unbekannter Fehler"
showStatus("Serverfehler: \(httpResponse.statusCode)\n\(msg)", error: true)
return
}
// Optional: Response parsen
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
showStatus("Gespeichert: \(resp.message)", error: false)
} else {
showStatus("Lesezeichen gespeichert!", error: false)
}
} catch {
showStatus("Netzwerkfehler: \(error.localizedDescription)", error: true)
}
}
private func showStatus(_ message: String, error: Bool) {
DispatchQueue.main.async {
self.statusLabel?.text = message
self.statusLabel?.textColor = error ? UIColor.systemRed : UIColor.systemGreen
self.statusLabel?.backgroundColor = error ? UIColor.systemRed.withAlphaComponent(0.1) : UIColor.systemGreen.withAlphaComponent(0.1)
self.statusLabel?.isHidden = false
if !error {
// Automatically dismiss after success
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
}
// MARK: - DTOs (kopiert)
private struct CreateBookmarkRequestDto: Codable {
let labels: [String]?
let title: String?
let url: String
init(url: String, title: String? = nil, labels: [String]? = nil) {
self.url = url
self.title = title
self.labels = labels
}
}
private struct CreateBookmarkResponseDto: Codable {
let message: String
let status: Int
} }
} }

View File

@ -1,84 +0,0 @@
import Foundation
class SimpleAPI {
// MARK: - API Methods
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
guard let token = KeychainHelper.shared.loadToken() else {
showStatus("No token found. Please log in via the main app.", true)
return
}
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
showStatus("No server endpoint found.", true)
return
}
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: labels)
guard let requestData = try? JSONEncoder().encode(requestDto) else {
showStatus("Failed to encode request.", true)
return
}
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
showStatus("Invalid server endpoint.", true)
return
}
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = requestData
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
showStatus("Invalid server response.", true)
return
}
guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
return
}
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
showStatus("Saved: \(resp.message)", false)
} else {
showStatus("Bookmark saved!", false)
}
} catch {
showStatus("Network error: \(error.localizedDescription)", true)
}
}
static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? {
guard let token = KeychainHelper.shared.loadToken() else {
showStatus("No token found. Please log in via the main app.", true)
return nil
}
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
showStatus("No server endpoint found.", true)
return nil
}
guard let apiUrl = URL(string: endpoint + "/api/bookmarks/labels") else {
showStatus("Invalid server endpoint.", true)
return nil
}
var request = URLRequest(url: apiUrl)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
showStatus("Invalid server response.", true)
return nil
}
guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
return nil
}
let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data)
return labels
} catch {
showStatus("Network error: \(error.localizedDescription)", true)
return nil
}
}
}

View File

@ -1,36 +0,0 @@
import Foundation
public struct CreateBookmarkRequestDto: Codable {
public let labels: [String]?
public let title: String?
public let url: String
public init(url: String, title: String? = nil, labels: [String]? = nil) {
self.url = url
self.title = title
self.labels = labels
}
}
public struct CreateBookmarkResponseDto: Codable {
public let message: String
public let status: Int
}
public struct BookmarkLabelDto: Codable, Identifiable {
public var id: String { href }
public let name: String
public let count: Int
public let href: String
public enum CodingKeys: String, CodingKey {
case name, count, href
}
public init(name: String, count: Int, href: String) {
self.name = name
self.count = count
self.href = href
}
}

View File

@ -4,7 +4,7 @@
<dict> <dict>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck</string> <string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View File

@ -1,133 +0,0 @@
// SPDX-License-Identifier: MIT
# Architecture Overview: readeck client
## 1. Introduction
**readeck client** is an open-source iOS project for conveniently managing and reading bookmarks. The app uses the MVVM architecture pattern and follows a clear layer structure: **UI**, **Domain**, and **Data**. A key feature is its own dependency injection (DI) based on Swift protocols and the factory pattern—completely without external libraries.
- **Architecture Pattern:** MVVM (Model-View-ViewModel) + Use Cases
- **Layers:** UI, Domain, Data
- **Technologies:** Swift, SwiftUI, CoreData, custom DI
- **DI:** Protocol-based, factory pattern, no external libraries
## 2. Architecture Overview
```mermaid
graph TD
UI["UI Layer\n(View, ViewModel)"]
Domain["Domain Layer\n(Use Cases, Models, Repository Protocols)"]
Data["Data Layer\n(Repository implementations, Database, Entities, API)"]
UI --> Domain
Domain --> Data
```
**Layer Overview:**
| Layer | Responsibility |
|---------|----------------------|
| UI | Presentation, user interaction, ViewModels, bindings |
| Domain | Business logic, use cases, models, repository protocols |
| Data | Repository implementations, database, entities, API |
## 3. Dependency Injection (DI)
**Goal:** Loose coupling, better testability, exchangeability of implementations.
**Approach:**
- Define protocols for dependencies (e.g., repository protocols)
- Implement the protocols in concrete classes
- Provide dependencies via a central factory
- Pass dependencies to ViewModels/use cases via initializers
**Example:**
```swift
// 1. Protocol definition
protocol PBookmarksRepository {
func getBookmarks() async throws -> [Bookmark]
}
// 2. Implementation
class BookmarksRepository: PBookmarksRepository {
func getBookmarks() async throws -> [Bookmark] {
// ...
}
}
// 3. Factory
class DefaultUseCaseFactory {
let bookmarksRepository: PBookmarksRepository = BookmarksRepository()
func makeGetBookmarksUseCase() -> GetBookmarksUseCase {
GetBookmarksUseCase(bookmarksRepository: bookmarksRepository)
}
}
// 4. ViewModel
class BookmarksViewModel: ObservableObject {
private let getBookmarksUseCase: GetBookmarksUseCase
init(factory: DefaultUseCaseFactory) {
self.getBookmarksUseCase = factory.makeGetBookmarksUseCase()
}
}
```
**Advantages:**
- Exchangeability (e.g., for tests)
- No dependency on frameworks
- Central management of all dependencies
## 4. Component Description
| Component | Responsibility |
|---------------------|---------------|
| View | UI elements, presentation, user interaction |
| ViewModel | Bridge between View & Domain, state management |
| Use Case | Encapsulates a business logic (e.g., create bookmark) |
| Repository Protocol | Interface between Domain & Data layer |
| Repository Implementation | Concrete implementation of repository protocols, handles data access |
| Data Source / API | Access to external data sources (API, CoreData, Keychain) |
| Model/Entity | Represents core data structures |
| Dependency Factory | Creates and manages dependencies, central DI point |
## 5. Data Flow
1. **User interaction** in the view triggers an action in the ViewModel.
2. The **ViewModel** calls a **use case**.
3. The **use case** uses a **repository protocol** to load/save data.
4. The **repository implementation** accesses a **data source** (e.g., API, CoreData).
5. The response flows back up to the view and is displayed.
## 6. Advantages of this Architecture
- **Testability:** Protocols and DI allow components to be tested in isolation.
- **Maintainability:** Clear separation of concerns, easy extensibility.
- **Modularity:** Layers can be developed and adjusted independently.
- **Independence:** No dependency on external DI or architecture frameworks.
## 7. Contributor Tips
- **New dependencies:** Always define as a protocol and register in the factory.
- **Protocols:** Define in the domain layer, implement in the data layer.
- **Factory:** Extend the factory for new use cases or repositories.
- **No external frameworks:** Intentionally use custom solutions for better control and clarity.
## 8. Glossary
| Term | Definition |
|---------------------|------------|
| Dependency Injection| Technique for providing dependencies from the outside |
| Protocol | Swift interface that defines requirements for types |
| Factory Pattern | Design pattern for central object creation |
| MVVM | Architecture: Model-View-ViewModel |
| Use Case | Encapsulates a specific business logic |
| Repository Protocol | Interface in the domain layer for data access |
| Repository Implementation | Concrete class in the data layer that fulfills a repository protocol |
| Data Source | Implementation for data access (API, DB, etc.) |
| Model/Entity | Core data structure used in domain or data layer |
## 9. Recommended Links
- [Clean Architecture (Uncle Bob)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Clean Architecture for Swift/iOS (adrian bilescu)](https://adrian-bilescu.medium.com/a-pragmatic-guide-to-clean-architecture-on-ios-e58d19d00559)
- [Swift.org: Protocols](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/protocols/)

View File

@ -1,6 +0,0 @@
app_identifier("de.ilyashallak.readeck2") # The bundle identifier of your app
# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username
# For more information about the Appfile, see:
# https://docs.fastlane.tools/advanced/#appfile

View File

@ -1,32 +0,0 @@
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:ios)
xcversion(version: "16.4.0")
platform :ios do
#desc "Generate new localized screenshots"
#lane :screenshots do
# capture_screenshots(scheme: "readeck")
# end
desc "Build and upload to TestFlight"
lane :beta do
build_app(scheme: "readeck")
upload_to_testflight
end
end

View File

@ -1,32 +0,0 @@
# Uncomment the lines below you want to change by removing the # in the beginning
# A list of devices you want to take the screenshots from
devices([
"iPhone 15 Pro",
#"iPad Pro (11-inch) (4th generation)"
])
languages([
"en-US",
"de-DE",
# "it-IT",
# ["pt", "pt_BR"] # Portuguese with Brazilian locale
])
# The name of the scheme which contains the UI Tests
scheme("readeck")
# Where should the resulting screenshots be stored?
output_directory("./screenshots")
# remove the '#' to clear all previously generated screenshots before creating new ones
clear_previous_screenshots(true)
# Remove the '#' to set the status bar to 9:41 AM, and show full battery and reception. See also override_status_bar_arguments for custom options.
override_status_bar(true)
# Arguments to pass to the app on launch. See https://docs.fastlane.tools/actions/snapshot/#launch-arguments
# launch_arguments(["-favColor red"])
# For more information about all available options run
# fastlane action snapshot

View File

@ -1,313 +0,0 @@
//
// SnapshotHelper.swift
// Example
//
// Created by Felix Krause on 10/8/15.
//
// -----------------------------------------------------
// IMPORTANT: When modifying this file, make sure to
// increment the version number at the very
// bottom of the file to notify users about
// the new SnapshotHelper.swift
// -----------------------------------------------------
import Foundation
import XCTest
@MainActor
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
@MainActor
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
} else {
Snapshot.snapshot(name, timeWaitingForIdle: 0)
}
}
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
@MainActor
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
enum SnapshotError: Error, CustomDebugStringConvertible {
case cannotFindSimulatorHomeDirectory
case cannotRunOnPhysicalDevice
var debugDescription: String {
switch self {
case .cannotFindSimulatorHomeDirectory:
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
case .cannotRunOnPhysicalDevice:
return "Can't use Snapshot on a physical device."
}
}
}
@objcMembers
@MainActor
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
static var cacheDirectory: URL?
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
static var deviceLanguage = ""
static var currentLocale = ""
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.app = app
Snapshot.waitForAnimations = waitForAnimations
do {
let cacheDir = try getCacheDirectory()
Snapshot.cacheDirectory = cacheDir
setLanguage(app)
setLocale(app)
setLaunchArguments(app)
} catch let error {
NSLog(error.localizedDescription)
}
}
class func setLanguage(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("language.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
} catch {
NSLog("Couldn't detect/set language...")
}
}
class func setLocale(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("locale.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
currentLocale = Locale(identifier: deviceLanguage).identifier
}
if !currentLocale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
}
}
class func setLaunchArguments(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
do {
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
let results = matches.map { result -> String in
(launchArguments as NSString).substring(with: result.range)
}
app.launchArguments += results
} catch {
NSLog("Couldn't detect/set launch_arguments...")
}
}
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
if timeout > 0 {
waitForLoadingIndicatorToDisappear(within: timeout)
}
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
if Snapshot.waitForAnimations {
sleep(1) // Waiting for the animation to be finished (kind of)
}
#if os(OSX)
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
#else
guard self.app != nil else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS) && !targetEnvironment(macCatalyst)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
#endif
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
do {
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
let range = NSRange(location: 0, length: simulator.count)
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
} catch let error {
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
NSLog(error.localizedDescription)
}
#endif
}
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
#if os(watchOS)
return image
#else
if #available(iOS 10.0, *) {
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
return renderer.image { context in
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
} else {
return image
}
#endif
}
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
#if os(tvOS)
return
#endif
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
}
class func getCacheDirectory() throws -> URL {
let cachePath = "Library/Caches/tools.fastlane"
// on OSX config is stored in /Users/<username>/Library
// and on iOS/tvOS/WatchOS it's in simulator's home dir
#if os(OSX)
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
return homeDir.appendingPathComponent(cachePath)
#elseif arch(i386) || arch(x86_64) || arch(arm64)
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
throw SnapshotError.cannotFindSimulatorHomeDirectory
}
let homeDir = URL(fileURLWithPath: simulatorHostHome)
return homeDir.appendingPathComponent(cachePath)
#else
throw SnapshotError.cannotRunOnPhysicalDevice
#endif
}
}
private extension XCUIElementAttributes {
var isNetworkLoadingIndicator: Bool {
if hasAllowListedIdentifier { return false }
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
}
var hasAllowListedIdentifier: Bool {
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
return allowListedIdentifiers.contains(identifier)
}
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
if elementType == .statusBar { return true }
guard frame.origin == .zero else { return false }
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
}
}
private extension XCUIElementQuery {
var networkLoadingIndicators: XCUIElementQuery {
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isNetworkLoadingIndicator
}
return self.containing(isNetworkLoadingIndicator)
}
@MainActor
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
}
let deviceWidth = app.windows.firstMatch.frame.width
let isStatusBar = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isStatusBar(deviceWidth)
}
return self.containing(isStatusBar)
}
}
private extension CGFloat {
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
return numberA...numberB ~= self
}
}
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.30]

View File

@ -10,6 +10,7 @@
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 */; }; 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 */; }; 5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
5DA242142E17D31A007531C3 /* 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 */
@ -85,13 +86,7 @@
Data/CoreData/CoreDataManager.swift, Data/CoreData/CoreDataManager.swift,
Data/KeychainHelper.swift, Data/KeychainHelper.swift,
Domain/Model/Bookmark.swift, Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift,
readeck.xcdatamodeld, readeck.xcdatamodeld,
Splash.storyboard,
UI/Components/Constants.swift,
UI/Components/CustomTextFieldStyle.swift,
UI/Components/TagManagementView.swift,
UI/Components/UnifiedLabelChip.swift,
); );
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */; target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
}; };
@ -148,6 +143,7 @@
files = ( files = (
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */, 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */, 5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -239,6 +235,7 @@
packageProductDependencies = ( packageProductDependencies = (
5D348CC22E0C9F4F00D0AF21 /* netfox */, 5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */, 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5DA241FC2E17C3B3007531C3 /* rswift */,
); );
productName = readeck; productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */; productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -449,14 +446,10 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare; PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -482,14 +475,10 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare; PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -553,7 +542,6 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -609,7 +597,6 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
}; };
@ -623,15 +610,13 @@
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 = 13; CURRENT_PROJECT_VERSION = 1;
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;
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_CFBundleDisplayName = "";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"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;
@ -640,21 +625,21 @@
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.1; IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.1; MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck; PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = auto; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 2.1; XROS_DEPLOYMENT_TARGET = 2.1;
}; };
name = Debug; name = Debug;
@ -667,15 +652,13 @@
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 = 13; CURRENT_PROJECT_VERSION = 1;
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;
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_CFBundleDisplayName = "";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"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;
@ -684,21 +667,21 @@
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.1; IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.1; MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck; PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = auto; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 2.1; XROS_DEPLOYMENT_TARGET = 2.1;
}; };
name = Release; name = Release;
@ -873,6 +856,11 @@
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */; package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = RswiftLibrary; productName = RswiftLibrary;
}; };
5DA241FC2E17C3B3007531C3 /* rswift */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = rswift;
};
5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */ = { 5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */; package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;

View File

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D2B7FAE2DFA27A400EBDB2B"
BuildableName = "URLShare.appex"
BlueprintName = "URLShare"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -1,102 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9DD2DF8586A0048D5B8"
BuildableName = "readeckTests.xctest"
BlueprintName = "readeckTests"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9E72DF8586A0048D5B8"
BuildableName = "readeckUITests.xctest"
BlueprintName = "readeckUITests"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -23,9 +23,9 @@
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "0x4B", "blue" : "0x5A",
"green" : "0x41", "green" : "0x4A",
"red" : "0x20" "red" : "0x1F"
} }
}, },
"idiom" : "universal" "idiom" : "universal"

View File

@ -1,59 +0,0 @@
{
"images" : [
{
"filename" : "logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "logo 5.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "logo 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "logo 4.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "logo 2.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "logo 3.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,26 +0,0 @@
{
"images" : [
{
"filename" : "readeck.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "readeck 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "readeck 2.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5A",
"green" : "0x4A",
"red" : "0x1F"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5B",
"green" : "0x4B",
"red" : "0x1F"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x4C",
"green" : "0x40",
"red" : "0x21"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -5,7 +5,7 @@ class KeychainHelper {
static let shared = KeychainHelper() static let shared = KeychainHelper()
private init() {} private init() {}
private static let accessGroup = "8J69P655GN.de.ilyashallak.readeck" private static let accessGroup = "8J69P655GN.de.ilyashallak.readeck2"
@discardableResult @discardableResult
func saveToken(_ token: String) -> Bool { func saveToken(_ token: String) -> Bool {

View File

@ -0,0 +1,57 @@
//
// Persistence.swift
// readeck
//
// Created by Ilyas Hallak on 10.06.25.
//
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
@MainActor
static let preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "readeck")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
}
}

View File

@ -31,8 +31,7 @@ class BookmarksRepository: PBookmarksRepository {
labels: bookmarkDetailDto.labels, labels: bookmarkDetailDto.labels,
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "", thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
imageUrl: bookmarkDetailDto.resources.image?.src ?? "", imageUrl: bookmarkDetailDto.resources.image?.src ?? "",
lang: bookmarkDetailDto.lang ?? "", lang: bookmarkDetailDto.lang ?? ""
readProgress: bookmarkDetailDto.readProgress
) )
} }

View File

@ -10,8 +10,6 @@ struct Settings {
var fontFamily: FontFamily? = nil var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var isLoggedIn: Bool { var isLoggedIn: Bool {
token != nil && !token!.isEmpty token != nil && !token!.isEmpty
@ -71,13 +69,6 @@ class SettingsRepository: PSettingsRepository {
if let fontSize = settings.fontSize { if let fontSize = settings.fontSize {
existingSettings.fontSize = fontSize.rawValue existingSettings.fontSize = fontSize.rawValue
} }
if let enableTTS = settings.enableTTS {
existingSettings.enableTTS = enableTTS
}
if let theme = settings.theme {
existingSettings.theme = theme.rawValue
}
try context.save() try context.save()
} }
@ -108,9 +99,7 @@ class SettingsRepository: PSettingsRepository {
password: settingEntity.password ?? "", password: settingEntity.password ?? "",
token: settingEntity.token, token: settingEntity.token,
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,
theme: Theme(rawValue: settingEntity.theme ?? Theme.system.rawValue)
) )
continuation.resume(returning: settings) continuation.resume(returning: settings)
} else { } else {

View File

@ -14,7 +14,7 @@ class CoreDataTokenProvider: TokenProvider {
private let keychainHelper = KeychainHelper.shared private let keychainHelper = KeychainHelper.shared
private func loadSettingsIfNeeded() async { private func loadSettingsIfNeeded() async {
guard isLoaded == false || cachedSettings == nil else { return } guard !isLoaded else { return }
do { do {
cachedSettings = try await settingsRepository.loadSettings() cachedSettings = try await settingsRepository.loadSettings()

View File

@ -12,14 +12,13 @@ struct BookmarkDetail {
let wordCount: Int? let wordCount: Int?
let readingTime: Int? let readingTime: Int?
let hasArticle: Bool let hasArticle: Bool
var isMarked: Bool let isMarked: Bool
var isArchived: Bool var isArchived: Bool
let labels: [String] let labels: [String]
let thumbnailUrl: String let thumbnailUrl: String
let imageUrl: String let imageUrl: String
let lang: String let lang: String
var content: String? var content: String?
let readProgress: Int?
} }
extension BookmarkDetail { extension BookmarkDetail {
@ -40,7 +39,6 @@ extension BookmarkDetail {
labels: [], labels: [],
thumbnailUrl: "", thumbnailUrl: "",
imageUrl: "", imageUrl: "",
lang: "", lang: ""
readProgress: 0
) )
} }

View File

@ -34,7 +34,7 @@ struct BookmarkUpdateRequest {
} }
} }
// Convenience Initializers für häufige Aktionen
extension BookmarkUpdateRequest { extension BookmarkUpdateRequest {
static func archive(_ isArchived: Bool) -> BookmarkUpdateRequest { static func archive(_ isArchived: Bool) -> BookmarkUpdateRequest {
return BookmarkUpdateRequest(isArchived: isArchived) return BookmarkUpdateRequest(isArchived: isArchived)

View File

@ -1,31 +0,0 @@
//
// Theme.swift
// readeck
//
// Created by Ilyas Hallak on 21.07.25.
//
import SwiftUI
enum Theme: String, CaseIterable {
case system = "system"
case light = "light"
case dark = "dark"
var displayName: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}

View File

@ -1,11 +1,6 @@
import Foundation import Foundation
protocol PAddLabelsToBookmarkUseCase { class AddLabelsToBookmarkUseCase {
func execute(bookmarkId: String, labels: [String]) async throws
func execute(bookmarkId: String, label: String) async throws
}
class AddLabelsToBookmarkUseCase: PAddLabelsToBookmarkUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PAddTextToSpeechQueueUseCase { class AddTextToSpeechQueueUseCase {
func execute(bookmarkDetail: BookmarkDetail)
}
class AddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
private let speechQueue: SpeechQueue private let speechQueue: SpeechQueue
init(speechQueue: SpeechQueue = .shared) { init(speechQueue: SpeechQueue = .shared) {

View File

@ -1,14 +1,6 @@
import Foundation import Foundation
protocol PCreateBookmarkUseCase { class CreateBookmarkUseCase {
func execute(createRequest: CreateBookmarkRequest) async throws -> String
func createFromURL(_ url: String) async throws -> String
func createFromURLWithTitle(_ url: String, title: String) async throws -> String
func createFromURLWithLabels(_ url: String, labels: [String]) async throws -> String
func createFromClipboard() async throws -> String?
}
class CreateBookmarkUseCase: PCreateBookmarkUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PDeleteBookmarkUseCase { class DeleteBookmarkUseCase {
func execute(bookmarkId: String) async throws
}
class DeleteBookmarkUseCase: PDeleteBookmarkUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PGetBookmarkArticleUseCase { class GetBookmarkArticleUseCase {
func execute(id: String) async throws -> String
}
class GetBookmarkArticleUseCase: PGetBookmarkArticleUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PGetBookmarkUseCase { class GetBookmarkUseCase {
func execute(id: String) async throws -> BookmarkDetail
}
class GetBookmarkUseCase: PGetBookmarkUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PGetBookmarksUseCase { class GetBookmarksUseCase {
func execute(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
}
class GetBookmarksUseCase: PGetBookmarksUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PGetLabelsUseCase { class GetLabelsUseCase {
func execute() async throws -> [BookmarkLabel]
}
class GetLabelsUseCase: PGetLabelsUseCase {
private let labelsRepository: PLabelsRepository private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) { init(labelsRepository: PLabelsRepository) {

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PLoadSettingsUseCase { class LoadSettingsUseCase {
func execute() async throws -> Settings?
}
class LoadSettingsUseCase: PLoadSettingsUseCase {
private let authRepository: PAuthRepository private let authRepository: PAuthRepository
init(authRepository: PAuthRepository) { init(authRepository: PAuthRepository) {

View File

@ -1,9 +1,4 @@
class LoginUseCase {
protocol PLoginUseCase {
func execute(endpoint: String, username: String, password: String) async throws -> User
}
class LoginUseCase: PLoginUseCase {
private let repository: PAuthRepository private let repository: PAuthRepository
init(repository: PAuthRepository) { init(repository: PAuthRepository) {

View File

@ -7,11 +7,11 @@
import Foundation import Foundation
protocol PLogoutUseCase { protocol LogoutUseCaseProtocol {
func execute() async throws func execute() async throws
} }
class LogoutUseCase: PLogoutUseCase { class LogoutUseCase: LogoutUseCaseProtocol {
private let settingsRepository: SettingsRepository private let settingsRepository: SettingsRepository
private let tokenManager: TokenManager private let tokenManager: TokenManager

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PReadBookmarkUseCase { class ReadBookmarkUseCase {
func execute(bookmarkDetail: BookmarkDetail)
}
class ReadBookmarkUseCase: PReadBookmarkUseCase {
private let addToSpeechQueue: AddTextToSpeechQueueUseCase private let addToSpeechQueue: AddTextToSpeechQueueUseCase
init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) { init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) {

View File

@ -1,11 +1,6 @@
import Foundation import Foundation
protocol PRemoveLabelsFromBookmarkUseCase { class RemoveLabelsFromBookmarkUseCase {
func execute(bookmarkId: String, labels: [String]) async throws
func execute(bookmarkId: String, label: String) async throws
}
class RemoveLabelsFromBookmarkUseCase: PRemoveLabelsFromBookmarkUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PSaveServerSettingsUseCase { class SaveServerSettingsUseCase {
func execute(endpoint: String, username: String, password: String, token: String) async throws
}
class SaveServerSettingsUseCase: PSaveServerSettingsUseCase {
private let repository: PSettingsRepository private let repository: PSettingsRepository
init(repository: PSettingsRepository) { init(repository: PSettingsRepository) {

View File

@ -1,15 +1,6 @@
import Foundation import Foundation
protocol PSaveSettingsUseCase { class SaveSettingsUseCase {
func execute(endpoint: String, username: String, password: String) async throws
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws
func execute(token: String) async throws
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
func execute(enableTTS: Bool) async throws
func execute(theme: Theme) async throws
}
class SaveSettingsUseCase: PSaveSettingsUseCase {
private let settingsRepository: PSettingsRepository private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) { init(settingsRepository: PSettingsRepository) {
@ -53,16 +44,4 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
) )
) )
} }
func execute(enableTTS: Bool) async throws {
try await settingsRepository.saveSettings(
.init(enableTTS: enableTTS)
)
}
func execute(theme: Theme) async throws {
try await settingsRepository.saveSettings(
.init(theme: theme)
)
}
} }

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
protocol PSearchBookmarksUseCase { class SearchBookmarksUseCase {
func execute(search: String) async throws -> BookmarksPage
}
class SearchBookmarksUseCase: PSearchBookmarksUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -1,18 +1,6 @@
import Foundation import Foundation
protocol PUpdateBookmarkUseCase { class UpdateBookmarkUseCase {
func execute(bookmarkId: String, updateRequest: BookmarkUpdateRequest) async throws
func toggleArchive(bookmarkId: String, isArchived: Bool) async throws
func toggleFavorite(bookmarkId: String, isMarked: Bool) async throws
func markAsDeleted(bookmarkId: String) async throws
func updateReadProgress(bookmarkId: String, progress: Int, anchor: String?) async throws
func updateTitle(bookmarkId: String, title: String) async throws
func updateLabels(bookmarkId: String, labels: [String]) async throws
func addLabels(bookmarkId: String, labels: [String]) async throws
func removeLabels(bookmarkId: String, labels: [String]) async throws
}
class UpdateBookmarkUseCase: PUpdateBookmarkUseCase {
private let repository: PBookmarksRepository private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) { init(repository: PBookmarksRepository) {

View File

@ -13,23 +13,16 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>NSAppTransportSecurity</key> <key>UILaunchScreen</key>
<dict> <dict>
<key>NSAllowsLocalNetworking</key> <key>UIColorName</key>
<true/> <string>green2</string>
<key>NSExceptionRequiresForwardSecrecy</key> <key>UIImageName</key>
<false/> <string>readeck</string>
</dict> </dict>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
</array> </array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>splashBackground</string>
<key>UIImageName</key>
<string>splash</string>
</dict>
</dict> </dict>
</plist> </plist>

View File

@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<device id="retina6_12" orientation="portrait" appearance="dark"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" clearsContextBeforeDrawing="NO" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="NbE-6K-ltk">
<rect key="frame" x="130" y="368" width="133.33333333333337" height="116"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="133.33000000000001" id="MuN-6D-myL"/>
<constraint firstAttribute="height" constant="115.67" id="ebY-kI-orh"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="TRS-0D-Iyx">
<rect key="frame" x="175" y="669" width="42" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" clearsContextBeforeDrawing="NO" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="bt9-XM-VsM">
<rect key="frame" x="155" y="59" width="82" height="80"/>
<constraints>
<constraint firstAttribute="width" constant="82" id="mei-64-UsF"/>
<constraint firstAttribute="width" secondItem="bt9-XM-VsM" secondAttribute="height" multiplier="41:40" id="wHS-wO-Ehi"/>
</constraints>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" name="BrightWhite"/>
<constraints>
<constraint firstItem="vDu-zF-Fre" firstAttribute="trailing" secondItem="bt9-XM-VsM" secondAttribute="trailing" constant="156" id="Nqh-Mz-6ie"/>
<constraint firstItem="NbE-6K-ltk" firstAttribute="centerX" secondItem="5EZ-qb-Rvc" secondAttribute="centerX" id="S8g-oD-g1Z"/>
<constraint firstItem="bt9-XM-VsM" firstAttribute="top" secondItem="5EZ-qb-Rvc" secondAttribute="top" constant="59" id="X6D-aF-ddp"/>
<constraint firstItem="vDu-zF-Fre" firstAttribute="trailing" secondItem="TRS-0D-Iyx" secondAttribute="trailing" constant="176" id="Ybu-ZP-2KF"/>
<constraint firstItem="bt9-XM-VsM" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" constant="155" id="Zfp-jh-UjO"/>
<constraint firstItem="TRS-0D-Iyx" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" constant="175" id="aBg-bZ-l74"/>
<constraint firstItem="NbE-6K-ltk" firstAttribute="centerY" secondItem="5EZ-qb-Rvc" secondAttribute="centerY" id="f9e-9Z-hjf"/>
<constraint firstItem="vDu-zF-Fre" firstAttribute="bottom" secondItem="TRS-0D-Iyx" secondAttribute="bottom" constant="94" id="iFw-vd-HUs"/>
<constraint firstItem="TRS-0D-Iyx" firstAttribute="top" secondItem="NbE-6K-ltk" secondAttribute="bottom" constant="185" id="mc7-gW-Rsl"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="125.95419847328243" y="-17.605633802816904"/>
</scene>
</scenes>
<resources>
<image name="logo" width="133.33332824707031" height="115.66666412353516"/>
<namedColor name="BrightWhite">
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@ -1,11 +1,8 @@
import SwiftUI import SwiftUI
import UIKit
struct AddBookmarkView: View { struct AddBookmarkView: View {
@State private var viewModel = AddBookmarkViewModel() @State private var viewModel = AddBookmarkViewModel()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@FocusState private var focusedField: AddBookmarkFieldFocus?
@State private var keyboardHeight: CGFloat = 0
init(prefilledURL: String? = nil, prefilledTitle: String? = nil) { init(prefilledURL: String? = nil, prefilledTitle: String? = nil) {
_viewModel = State(initialValue: AddBookmarkViewModel()) _viewModel = State(initialValue: AddBookmarkViewModel())
@ -20,230 +17,212 @@ struct AddBookmarkView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(spacing: 0) { VStack(spacing: 0) {
formContent // Scrollable Form Content
bottomActionArea ScrollView {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Image(systemName: "bookmark.circle.fill")
.font(.system(size: 48))
.foregroundColor(.accentColor)
Text("Neues Bookmark")
.font(.title2)
.fontWeight(.semibold)
Text("Füge einen neuen Link zu deiner Sammlung hinzu")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.top, 20)
// Form Fields
VStack(spacing: 20) {
// URL Field
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("URL", systemImage: "link")
.font(.headline)
.foregroundColor(.primary)
Spacer()
Text("Erforderlich")
.font(.caption)
.foregroundColor(.red)
}
TextField("https://example.com", text: $viewModel.url)
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.URL)
.autocapitalization(.none)
.autocorrectionDisabled()
}
// Title Field
VStack(alignment: .leading, spacing: 8) {
Label("Titel", systemImage: "note.text")
.font(.headline)
.foregroundColor(.primary)
TextField("Optional: Eigener Titel", text: $viewModel.title)
.textFieldStyle(CustomTextFieldStyle())
}
// Labels Field
VStack(alignment: .leading, spacing: 8) {
Label("Labels", systemImage: "tag")
.font(.headline)
.foregroundColor(.primary)
TextField("z.B. arbeit, wichtig, später", text: $viewModel.labelsText)
.textFieldStyle(CustomTextFieldStyle())
// Labels Preview
if !viewModel.parsedLabels.isEmpty {
LazyVGrid(columns: [
GridItem(.adaptive(minimum: 80))
], spacing: 8) {
ForEach(viewModel.parsedLabels, id: \.self) { label in
Text(label)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.accentColor.opacity(0.1))
.foregroundColor(.accentColor)
.clipShape(Capsule())
}
}
.padding(.top, 8)
}
}
// Clipboard Section
if viewModel.clipboardURL != nil {
VStack(alignment: .leading, spacing: 12) {
Label("Zwischenablage", systemImage: "doc.on.clipboard")
.font(.headline)
.foregroundColor(.primary)
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("URL gefunden:")
.font(.caption)
.foregroundColor(.secondary)
Text(viewModel.clipboardURL ?? "")
.font(.subheadline)
.lineLimit(2)
.truncationMode(.middle)
}
Spacer()
Button("Einfügen") {
viewModel.pasteFromClipboard()
}
.buttonStyle(SecondaryButtonStyle())
}
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
.padding(.horizontal, 20)
Spacer(minLength: 100) // Platz für Button
}
}
// Bottom Action Area
VStack(spacing: 16) {
Divider()
VStack(spacing: 12) {
// Save Button
Button(action: {
Task {
await viewModel.createBookmark()
if viewModel.hasCreated {
dismiss()
}
}
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
.foregroundColor(.white)
} else {
Image(systemName: "bookmark.fill")
}
Text(viewModel.isLoading ? "Wird gespeichert..." : "Bookmark speichern")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(viewModel.isValid && !viewModel.isLoading ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!viewModel.isValid || viewModel.isLoading)
// Cancel Button
Button("Abbrechen") {
dismiss()
viewModel.clearForm()
}
.foregroundColor(.secondary)
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
.background(Color(.systemBackground))
} }
.navigationTitle("New Bookmark")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Close") { Button("Schließen") {
dismiss() dismiss()
viewModel.clearForm() viewModel.clearForm()
} }
.foregroundColor(.secondary)
} }
} }
.alert("Error", isPresented: $viewModel.showErrorAlert) { .alert("Fehler", isPresented: $viewModel.showErrorAlert) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
} message: { } message: {
Text(viewModel.errorMessage ?? "Unknown error") Text(viewModel.errorMessage ?? "Unbekannter Fehler")
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
}
.ignoresSafeArea(.keyboard)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardFrame.height
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
keyboardHeight = 0
} }
} }
.onAppear { .onAppear {
viewModel.checkClipboard() viewModel.checkClipboard()
} }
.task {
await viewModel.loadAllLabels()
}
.onDisappear { .onDisappear {
viewModel.clearForm() viewModel.clearForm()
} }
} }
// MARK: - View Components
@ViewBuilder
private var formContent: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 20) {
VStack(spacing: 20) {
urlField
.id("urlField")
Spacer()
.frame(height: 40)
.id("labelsOffset")
labelsField
.id("labelsField")
titleField
.id("titleField")
}
.padding(.horizontal, 20)
Spacer(minLength: 120)
}
.padding(.top, 20) // Add top padding for offset
}
.padding(.bottom, keyboardHeight / 2)
.onChange(of: focusedField) { field in
guard let field = field else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(.easeInOut(duration: 0.3)) {
switch field {
case .url:
proxy.scrollTo("urlField", anchor: .top)
case .labels:
proxy.scrollTo("labelsOffset", anchor: .top)
case .title:
proxy.scrollTo("titleField", anchor: .center)
}
}
}
}
}
}
@ViewBuilder
private var urlField: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("https://example.com", text: $viewModel.url)
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.URL)
.autocapitalization(.none)
.autocorrectionDisabled()
.focused($focusedField, equals: .url)
.onChange(of: viewModel.url) { _, _ in
viewModel.checkClipboard()
}
clipboardButton
}
}
@ViewBuilder
private var clipboardButton: some View {
if viewModel.showClipboardButton {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("URL in clipboard:")
.font(.caption)
.foregroundColor(.secondary)
Text(viewModel.clipboardURL ?? "")
.font(.subheadline)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
HStack(spacing: 8) {
Button("Paste") {
viewModel.pasteFromClipboard()
}
.buttonStyle(SecondaryButtonStyle())
Button(action: {
viewModel.dismissClipboard()
}) {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
@ViewBuilder
private var titleField: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("Optional: Custom title", text: $viewModel.title)
.textFieldStyle(CustomTextFieldStyle())
.focused($focusedField, equals: .title)
}
}
@ViewBuilder
private var labelsField: some View {
TagManagementView(
allLabels: viewModel.allLabels,
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: viewModel.isLabelsLoading,
availableLabelPages: viewModel.availableLabelPages,
filteredLabels: viewModel.filteredLabels,
searchFieldFocus: $focusedField,
onAddCustomTag: {
viewModel.addCustomTag()
},
onToggleLabel: { label in
viewModel.toggleLabel(label)
},
onRemoveLabel: { label in
viewModel.removeLabel(label)
}
)
}
@ViewBuilder
private var bottomActionArea: some View {
VStack(spacing: 16) {
VStack(spacing: 12) {
saveButton
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
.background(Color(.systemBackground))
}
@ViewBuilder
private var saveButton: some View {
Button(action: {
Task {
await viewModel.createBookmark()
if viewModel.hasCreated {
dismiss()
}
}
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
.foregroundColor(.white)
} else {
Image(systemName: "bookmark.fill")
}
Text(viewModel.isLoading ? "Saving..." : "Save bookmark")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(viewModel.isValid && !viewModel.isLoading ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!viewModel.isValid || viewModel.isLoading)
}
} }
// MARK: - Custom Styles // MARK: - Custom Styles
struct CustomTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(.systemGray4), lineWidth: 1)
)
}
}
struct SecondaryButtonStyle: ButtonStyle { struct SecondaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label

View File

@ -3,37 +3,17 @@ import UIKit
@Observable @Observable
class AddBookmarkViewModel { class AddBookmarkViewModel {
// MARK: - Dependencies
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase() private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
// MARK: - Form Data
var url: String = "" var url: String = ""
var title: String = "" var title: String = ""
var labelsText: String = "" var labelsText: String = ""
// MARK: - Labels/Tags Management
var allLabels: [BookmarkLabel] = []
var selectedLabels: Set<String> = []
var searchText: String = ""
var isLabelsLoading: Bool = false
// MARK: - UI State
var isLoading: Bool = false var isLoading: Bool = false
var errorMessage: String? var errorMessage: String?
var showErrorAlert: Bool = false var showErrorAlert: Bool = false
var hasCreated: Bool = false var hasCreated: Bool = false
// MARK: - Clipboard Management
var clipboardURL: String? var clipboardURL: String?
var showClipboardButton: Bool = false
// MARK: - Computed Properties
var isValid: Bool { var isValid: Bool {
!url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
@ -47,82 +27,6 @@ class AddBookmarkViewModel {
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
} }
var availableLabels: [BookmarkLabel] {
return allLabels.filter { !selectedLabels.contains($0.name) }
}
var filteredLabels: [BookmarkLabel] {
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var availableLabelPages: [[BookmarkLabel]] {
let pageSize = Constants.Labels.pageSize
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
if labelsToShow.count <= pageSize {
return [labelsToShow]
} else {
return stride(from: 0, to: labelsToShow.count, by: pageSize).map {
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
}
}
}
// MARK: - Labels Management
@MainActor
func loadAllLabels() async {
isLabelsLoading = true
defer { isLabelsLoading = false }
do {
let labels = try await getLabelsUseCase.execute()
allLabels = labels.sorted { $0.count > $1.count }
} catch {
errorMessage = "Failed to load labels"
showErrorAlert = true
}
}
@MainActor
func addCustomTag() {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let lowercased = trimmed.lowercased()
let allExisting = Set(allLabels.map { $0.name.lowercased() })
let allSelected = Set(selectedLabels.map { $0.lowercased() })
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
// Tag already exists, don't add
return
} else {
selectedLabels.insert(trimmed)
searchText = ""
}
}
@MainActor
func toggleLabel(_ label: String) {
if selectedLabels.contains(label) {
selectedLabels.remove(label)
} else {
selectedLabels.insert(label)
}
searchText = ""
}
@MainActor
func removeLabel(_ label: String) {
selectedLabels.remove(label)
}
// MARK: - Bookmark Creation
@MainActor @MainActor
func createBookmark() async { func createBookmark() async {
guard isValid else { return } guard isValid else { return }
@ -134,7 +38,7 @@ class AddBookmarkViewModel {
do { do {
let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines) let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let labels = Array(selectedLabels) let labels = parsedLabels
let request = CreateBookmarkRequest( let request = CreateBookmarkRequest(
url: cleanURL, url: cleanURL,
@ -144,7 +48,7 @@ class AddBookmarkViewModel {
let message = try await createBookmarkUseCase.execute(createRequest: request) let message = try await createBookmarkUseCase.execute(createRequest: request)
// Optional: Show the server message // Optional: Zeige die Server-Nachricht an
print("Server response: \(message)") print("Server response: \(message)")
clearForm() clearForm()
@ -153,52 +57,31 @@ class AddBookmarkViewModel {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
showErrorAlert = true showErrorAlert = true
} catch { } catch {
errorMessage = "Error creating bookmark" errorMessage = "Fehler beim Erstellen des Bookmarks"
showErrorAlert = true showErrorAlert = true
} }
isLoading = false isLoading = false
} }
// MARK: - Clipboard Management
func checkClipboard() { func checkClipboard() {
guard let clipboardString = UIPasteboard.general.string, guard let clipboardString = UIPasteboard.general.string,
URL(string: clipboardString) != nil else { URL(string: clipboardString) != nil else {
clipboardURL = nil clipboardURL = nil
showClipboardButton = false
return return
} }
// Only show clipboard button if the URL is different from current URL clipboardURL = clipboardString
let currentURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
if clipboardString != currentURL {
clipboardURL = clipboardString
showClipboardButton = true
} else {
showClipboardButton = false
}
} }
func pasteFromClipboard() { func pasteFromClipboard() {
guard let clipboardURL = clipboardURL else { return } guard let clipboardURL = clipboardURL else { return }
url = clipboardURL url = clipboardURL
showClipboardButton = false
} }
func dismissClipboard() {
showClipboardButton = false
}
// MARK: - Form Management
func clearForm() { func clearForm() {
url = "" url = ""
title = "" title = ""
labelsText = "" labelsText = ""
selectedLabels.removeAll()
searchText = ""
clipboardURL = nil
showClipboardButton = false
} }
} }

View File

@ -1,114 +1,32 @@
import SwiftUI import SwiftUI
import SafariServices import SafariServices
import Combine
struct BookmarkDetailView: View { struct BookmarkDetailView: View {
let bookmarkId: String let bookmarkId: String
@State private var viewModel = BookmarkDetailViewModel()
// MARK: - States
@State private var viewModel: BookmarkDetailViewModel
@State private var webViewHeight: CGFloat = 300 @State private var webViewHeight: CGFloat = 300
@State private var showingFontSettings = false @State private var showingFontSettings = false
@State private var showingLabelsSheet = false @State private var showingLabelsSheet = false
@State private var readingProgress: Double = 0.0
@State private var scrollViewHeight: CGFloat = 1
@State private var showJumpToProgressButton: Bool = false
@State private var scrollPosition = ScrollPosition(edge: .top)
// MARK: - Envs
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
@Environment(\.dismiss) private var dismiss
private let headerHeight: CGFloat = 320 private let headerHeight: CGFloat = 320
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId
self.viewModel = viewModel
self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings
self.showingLabelsSheet = showingLabelsSheet
}
var body: some View { var body: some View {
VStack(spacing: 0) { GeometryReader { geometry in
ProgressView(value: readingProgress) ScrollView {
.progressViewStyle(LinearProgressViewStyle()) ZStack(alignment: .top) {
.frame(height: 3) headerView(geometry: geometry)
GeometryReader { outerGeo in VStack(alignment: .leading, spacing: 16) {
ScrollView { Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
VStack(spacing: 0) { titleSection
GeometryReader { geo in Divider().padding(.horizontal)
Color.clear contentSection
.preference(key: ScrollOffsetPreferenceKey.self, Spacer(minLength: 40)
value: geo.frame(in: .named("scroll")).minY) archiveSection
}
.frame(height: 0)
ZStack(alignment: .top) {
headerView(geometry: outerGeo)
VStack(alignment: .leading, spacing: 16) {
Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
titleSection
Divider().padding(.horizontal)
if showJumpToProgressButton {
JumpButton()
}
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in
if webViewHeight != height {
webViewHeight = height
}
})
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal)
.animation(.easeInOut, value: webViewHeight)
} else if viewModel.isLoadingArticle {
ProgressView("Loading article...")
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
}) {
HStack {
Image(systemName: "safari")
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal)
.padding(.top, 0)
}
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
VStack(alignment: .center) {
archiveSection
.transition(.opacity.combined(with: .move(edge: .bottom)))
.animation(.easeInOut, value: viewModel.articleContent)
}
.frame(maxWidth: .infinity)
}
}
}
} }
} }
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
scrollViewHeight = outerGeo.size.height
let maxOffset = webViewHeight - scrollViewHeight
let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1)
let progress = min(max(rawProgress, 0), 1)
readingProgress = progress
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
}
.ignoresSafeArea(edges: .top)
.scrollPosition($scrollPosition)
} }
.ignoresSafeArea(edges: .top)
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -138,11 +56,11 @@ struct BookmarkDetailView: View {
Spacer() Spacer()
} }
.navigationTitle("Font Settings") .navigationTitle("Schrift-Einstellungen")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { Button("Fertig") {
showingFontSettings = false showingFontSettings = false
} }
} }
@ -168,9 +86,6 @@ struct BookmarkDetailView: View {
} }
} }
} }
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.task { .task {
await viewModel.loadBookmarkDetail(id: bookmarkId) await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId) await viewModel.loadArticleContent(id: bookmarkId)
@ -237,16 +152,13 @@ struct BookmarkDetailView: View {
private var contentSection: some View { private var contentSection: some View {
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
withAnimation(.easeInOut(duration: 0.1)) { webViewHeight = height
webViewHeight = height
}
} }
.frame(height: webViewHeight) .frame(height: webViewHeight)
.cornerRadius(14) .cornerRadius(14)
.padding(.horizontal) .padding(.horizontal)
.animation(.easeInOut, value: webViewHeight)
} else if viewModel.isLoadingArticle { } else if viewModel.isLoadingArticle {
ProgressView("Loading article...") ProgressView("Lade Artikel...")
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding() .padding()
} else { } else {
@ -255,24 +167,24 @@ 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: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen")
} }
.font(.title3.bold()) .font(.title3.bold())
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 0) .padding(.top, 32)
} }
} }
private var metaInfoSection: some View { private var metaInfoSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if !viewModel.bookmarkDetail.authors.isEmpty { if !viewModel.bookmarkDetail.authors.isEmpty {
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", ")) metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Autor:innen: " : "Autor: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
} }
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created)) metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words\(viewModel.bookmarkDetail.readingTime ?? 0) min read") metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter\(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit")
// Labels section // Labels section
if !viewModel.bookmarkDetail.labels.isEmpty { if !viewModel.bookmarkDetail.labels.isEmpty {
@ -309,22 +221,20 @@ struct BookmarkDetailView: View {
Button(action: { Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url) SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
}) { }) {
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open") Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
if appSettings.enableTTS { metaRow(icon: "speaker.wave.2") {
metaRow(icon: "speaker.wave.2") { Button(action: {
Button(action: { viewModel.addBookmarkToSpeechQueue()
viewModel.addBookmarkToSpeechQueue() playerUIState.showPlayer()
playerUIState.showPlayer() }) {
}) { Text("Artikel vorlesen")
Text("Read article aloud") .font(.subheadline)
.font(.subheadline) .foregroundColor(.secondary)
.foregroundColor(.secondary)
}
} }
} }
} }
@ -363,48 +273,31 @@ struct BookmarkDetailView: View {
let displayFormatter = DateFormatter() let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium displayFormatter.dateStyle = .medium
displayFormatter.timeStyle = .short displayFormatter.timeStyle = .short
displayFormatter.locale = .autoupdatingCurrent displayFormatter.locale = Locale(identifier: "de_DE")
return displayFormatter.string(from: date) return displayFormatter.string(from: date)
} }
return dateString return dateString
} }
private var archiveSection: some View { private var archiveSection: some View {
VStack(alignment: .center, spacing: 12) { VStack(spacing: 12) {
Text("Finished reading?") Text("Fertig mit Lesen?")
.font(.headline) .font(.headline)
.padding(.top, 24) .padding(.top, 24)
VStack(alignment: .center, spacing: 16) { if viewModel.bookmarkDetail.isArchived {
Label("Bookmark ist archiviert", systemImage: "archivebox.fill")
} else {
Button(action: { Button(action: {
Task { Task {
await viewModel.toggleFavorite(id: bookmarkId) await viewModel.archiveBookmark(id: bookmarkId)
} }
}) { }) {
HStack { HStack {
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star") Image(systemName: "archivebox")
.foregroundColor(viewModel.bookmarkDetail.isMarked ? .yellow : .gray) Text("Bookmark archivieren")
Text(viewModel.bookmarkDetail.isMarked ? "Favorite" : "Mark as favorite")
} }
.font(.title3.bold()) .font(.title3.bold())
.frame(maxHeight: 60) .frame(maxWidth: .infinity, maxHeight: 40)
.padding(10)
}
.buttonStyle(.bordered)
.disabled(viewModel.isLoading)
// Archive button
Button(action: {
Task {
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
}
}) {
HStack {
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark")
}
.font(.title3.bold())
.frame(maxHeight: 60)
.padding(10)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading) .disabled(viewModel.isLoading)
@ -418,43 +311,10 @@ struct BookmarkDetailView: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 32) .padding(.bottom, 32)
} }
@ViewBuilder
func JumpButton() -> some View {
Button(action: {
let maxOffset = webViewHeight - scrollViewHeight
let offset = maxOffset * (Double(viewModel.readProgress) / 100.0)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
scrollPosition = ScrollPosition(y: offset)
showJumpToProgressButton = false
}
}) {
Text("Jump to last read position (\(viewModel.readProgress)%)")
.font(.subheadline)
.padding(8)
.frame(maxWidth: .infinity)
}
.background(Color.accentColor.opacity(0.15))
.cornerRadius(8)
.padding([.top, .horizontal])
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
} }
#Preview { #Preview {
NavigationView { NavigationView {
BookmarkDetailView(bookmarkId: "123", BookmarkDetailView(bookmarkId: "sample-id")
viewModel: .init(MockUseCaseFactory()),
webViewHeight: 300,
showingFontSettings: false,
showingLabelsSheet: false,
playerUIState: .init())
} }
} }

View File

@ -1,44 +1,29 @@
import Foundation import Foundation
import Combine
@Observable @Observable
class BookmarkDetailViewModel { class BookmarkDetailViewModel {
private let getBookmarkUseCase: PGetBookmarkUseCase private let getBookmarkUseCase: GetBookmarkUseCase
private let getBookmarkArticleUseCase: PGetBookmarkArticleUseCase private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase private let loadSettingsUseCase: LoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase private let updateBookmarkUseCase: UpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase? private let addTextToSpeechQueueUseCase: AddTextToSpeechQueueUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = "" var articleContent: String = ""
var articleParagraphs: [String] = [] var articleParagraphs: [String] = []
var bookmark: Bookmark? = nil var bookmark: Bookmark? = nil
var isLoading = false var isLoading = false
var isLoadingArticle = true var isLoadingArticle = false
var errorMessage: String? var errorMessage: String?
var settings: Settings? var settings: Settings?
var readProgress: Int = 0
private var factory: UseCaseFactory? init() {
private var cancellables = Set<AnyCancellable>() let factory = DefaultUseCaseFactory.shared
private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>()
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
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() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.factory = factory self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase()
readProgressSubject
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink { [weak self] (id, progress, anchor) in
let progressInt = Int(progress * 100)
Task {
await self?.updateReadProgress(id: id, progress: progressInt, anchor: anchor)
}
}
.store(in: &cancellables)
} }
@MainActor @MainActor
@ -49,16 +34,8 @@ 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)
// Always take the higher value between server and local progress
let serverProgress = bookmarkDetail.readProgress ?? 0
readProgress = max(readProgress, serverProgress)
if settings?.enableTTS == true {
self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase()
}
} catch { } catch {
errorMessage = "Error loading bookmark" errorMessage = "Fehler beim Laden des Bookmarks"
} }
isLoading = false isLoading = false
@ -72,7 +49,7 @@ class BookmarkDetailViewModel {
articleContent = try await getBookmarkArticleUseCase.execute(id: id) articleContent = try await getBookmarkArticleUseCase.execute(id: id)
processArticleContent() processArticleContent()
} catch { } catch {
errorMessage = "Error loading article" errorMessage = "Fehler beim Laden des Artikels"
} }
isLoadingArticle = false isLoadingArticle = false
@ -87,14 +64,14 @@ class BookmarkDetailViewModel {
} }
@MainActor @MainActor
func archiveBookmark(id: String, isArchive: Bool = true) async { func archiveBookmark(id: String) async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
do { do {
try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: isArchive) try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true)
bookmarkDetail.isArchived = true bookmarkDetail.isArchived = true
} catch { } catch {
errorMessage = "Error archiving bookmark" errorMessage = "Fehler beim Archivieren des Bookmarks"
} }
isLoading = false isLoading = false
} }
@ -106,35 +83,6 @@ class BookmarkDetailViewModel {
func addBookmarkToSpeechQueue() { func addBookmarkToSpeechQueue() {
bookmarkDetail.content = articleContent bookmarkDetail.content = articleContent
addTextToSpeechQueueUseCase?.execute(bookmarkDetail: bookmarkDetail) addTextToSpeechQueueUseCase.execute(bookmarkDetail: bookmarkDetail)
}
@MainActor
func toggleFavorite(id: String) async {
isLoading = true
errorMessage = nil
do {
let newValue = !bookmarkDetail.isMarked
try await updateBookmarkUseCase.toggleFavorite(bookmarkId: id, isMarked: newValue)
bookmarkDetail.isMarked = newValue
} catch {
errorMessage = "Error updating favorite status"
}
isLoading = false
}
func updateReadProgress(id: String, progress: Int, anchor: String?) async {
// Only update if the new progress is higher than current
if progress > readProgress {
do {
try await updateBookmarkUseCase.updateReadProgress(bookmarkId: id, progress: progress, anchor: anchor)
} catch {
// ignore error in this case
}
}
}
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
readProgressSubject.send((id, progress, anchor))
} }
} }

View File

@ -5,136 +5,172 @@ struct BookmarkLabelsView: View {
@State private var viewModel: BookmarkLabelsViewModel @State private var viewModel: BookmarkLabelsViewModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) { init(bookmarkId: String, initialLabels: [String]) {
self.bookmarkId = bookmarkId self.bookmarkId = bookmarkId
self._viewModel = State(initialValue: viewModel ?? BookmarkLabelsViewModel(initialLabels: initialLabels)) self._viewModel = State(initialValue: BookmarkLabelsViewModel(initialLabels: initialLabels))
UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(Color.primary)
UIPageControl.appearance().pageIndicatorTintColor = UIColor(Color.primary).withAlphaComponent(0.2)
} }
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(spacing: 12) { VStack(spacing: 16) {
searchSection // Add new label section
availableLabelsSection addLabelSection
Divider()
.padding(.horizontal, -16)
// Current labels section
currentLabelsSection
Spacer() Spacer()
} }
.padding(.vertical) .padding()
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("Manage Labels") .navigationTitle("Labels verwalten")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
Button("Abbrechen") {
dismiss() dismiss()
} }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { Button("Fertig") {
dismiss() dismiss()
} }
} }
} }
.alert("Error", isPresented: $viewModel.showErrorAlert) { .alert("Fehler", isPresented: $viewModel.showErrorAlert) {
Button("OK") { } Button("OK") { }
} message: { } message: {
Text(viewModel.errorMessage ?? "Unknown error") Text(viewModel.errorMessage ?? "Unbekannter Fehler")
}
.task {
await viewModel.loadAllLabels()
}
.ignoresSafeArea(.keyboard)
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
} }
} }
} }
// MARK: - View Components private var addLabelSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Neues Label hinzufügen")
.font(.headline)
.foregroundColor(.primary)
@ViewBuilder HStack(spacing: 12) {
private var searchSection: some View { TextField("Label eingeben...", text: $viewModel.newLabelText)
VStack(spacing: 8) { .textFieldStyle(RoundedBorderTextFieldStyle())
searchField .onSubmit {
customTagSuggestion Task {
} await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
.padding(.horizontal) }
} }
@ViewBuilder
private var searchField: some View {
TextField("Search or add new tag...", text: $viewModel.searchText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
}
}
}
@ViewBuilder
private var customTagSuggestion: some View {
if !viewModel.searchText.isEmpty &&
!viewModel.filteredLabels.contains(where: { $0.name.lowercased() == viewModel.searchText.lowercased() }) {
HStack {
Text("Add new tag:")
.font(.caption)
.foregroundColor(.secondary)
Text(viewModel.searchText)
.font(.caption)
.fontWeight(.medium)
Spacer()
Button(action: { Button(action: {
Task { Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
} }
}) { }) {
HStack(spacing: 6) { Image(systemName: "plus.circle.fill")
Image(systemName: "plus.circle.fill") .font(.title2)
.font(.caption) .foregroundColor(.white)
Text("Add") .frame(width: 32, height: 32)
.font(.caption) .background(
.fontWeight(.medium) Circle()
} .fill(Color.accentColor)
)
} }
.foregroundColor(.accentColor) .disabled(viewModel.newLabelText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading)
} }
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(Color.accentColor.opacity(0.1))
.cornerRadius(10)
} }
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
)
} }
@ViewBuilder private var currentLabelsSection: some View {
private var availableLabelsSection: some View { VStack(alignment: .leading, spacing: 8) {
TagManagementView( HStack {
allLabels: viewModel.allLabels, Text("Aktuelle Labels")
selectedLabels: Set(viewModel.currentLabels), .font(.headline)
searchText: $viewModel.searchText, .foregroundColor(.primary)
isLabelsLoading: viewModel.isInitialLoading,
availableLabelPages: viewModel.availableLabelPages, Spacer()
filteredLabels: viewModel.filteredLabels,
onAddCustomTag: { if viewModel.isLoading {
Task { ProgressView()
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) .scaleEffect(0.8)
}
},
onToggleLabel: { label in
Task {
await viewModel.toggleLabel(for: bookmarkId, label: label)
}
},
onRemoveLabel: { label in
Task {
await viewModel.removeLabel(from: bookmarkId, label: label)
} }
} }
if viewModel.currentLabels.isEmpty {
VStack(spacing: 8) {
Image(systemName: "tag")
.font(.title2)
.foregroundColor(.secondary)
Text("Keine Labels vorhanden")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else {
LazyVGrid(columns: [
GridItem(.adaptive(minimum: 80, maximum: 150))
], spacing: 4) {
ForEach(viewModel.currentLabels, id: \.self) { label in
LabelChip(
label: label,
onRemove: {
Task {
await viewModel.removeLabel(from: bookmarkId, label: label)
}
}
)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
)
}
}
struct LabelChip: View {
let label: String
let onRemove: () -> Void
var body: some View {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.accentColor.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.accentColor.opacity(0.4), lineWidth: 1)
)
) )
} }
} }
#Preview { #Preview {
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"], viewModel: .init(MockUseCaseFactory(), initialLabels: ["test"])) BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"])
} }

View File

@ -2,72 +2,17 @@ import Foundation
@Observable @Observable
class BookmarkLabelsViewModel { class BookmarkLabelsViewModel {
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase private let addLabelsUseCase = DefaultUseCaseFactory.shared.makeAddLabelsToBookmarkUseCase()
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase private let removeLabelsUseCase = DefaultUseCaseFactory.shared.makeRemoveLabelsFromBookmarkUseCase()
private let getLabelsUseCase: PGetLabelsUseCase
var isLoading = false var isLoading = false
var isInitialLoading = false
var errorMessage: String? var errorMessage: String?
var showErrorAlert = false var showErrorAlert = false
var currentLabels: [String] = [] { var currentLabels: [String] = []
didSet {
calculatePages()
}
}
var newLabelText = "" var newLabelText = ""
var searchText = "" {
didSet {
calculatePages()
}
}
var allLabels: [BookmarkLabel] = [] { init(initialLabels: [String] = []) {
didSet {
calculatePages()
}
}
var labelPages: [[BookmarkLabel]] = []
// Computed property for available labels (excluding current labels)
var availableLabels: [BookmarkLabel] {
return allLabels.filter { currentLabels.contains($0.name) == false }
}
// Computed property for filtered labels based on search text
var filteredLabels: [BookmarkLabel] {
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var availableLabelPages: [[BookmarkLabel]] = []
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
self.currentLabels = initialLabels self.currentLabels = initialLabels
self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase()
self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase()
self.getLabelsUseCase = factory.makeGetLabelsUseCase()
}
@MainActor
func loadAllLabels() async {
isInitialLoading = true
defer { isInitialLoading = false }
do {
let labels = try await getLabelsUseCase.execute()
allLabels = labels
} catch {
errorMessage = "failed to load labels"
showErrorAlert = true
}
calculatePages()
} }
@MainActor @MainActor
@ -76,20 +21,19 @@ class BookmarkLabelsViewModel {
errorMessage = nil errorMessage = nil
do { do {
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
// Update local labels
currentLabels.append(contentsOf: labels) currentLabels.append(contentsOf: labels)
currentLabels = Array(Set(currentLabels)) // Remove duplicates currentLabels = Array(Set(currentLabels)) // Remove duplicates
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
} catch let error as BookmarkUpdateError { } catch let error as BookmarkUpdateError {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
showErrorAlert = true showErrorAlert = true
} catch { } catch {
errorMessage = "Error adding labels" errorMessage = "Fehler beim Hinzufügen der Labels"
showErrorAlert = true showErrorAlert = true
} }
isLoading = false isLoading = false
calculatePages()
} }
@MainActor @MainActor
@ -99,7 +43,6 @@ class BookmarkLabelsViewModel {
await addLabels(to: bookmarkId, labels: [trimmedLabel]) await addLabels(to: bookmarkId, labels: [trimmedLabel])
newLabelText = "" newLabelText = ""
searchText = ""
} }
@MainActor @MainActor
@ -115,12 +58,11 @@ class BookmarkLabelsViewModel {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
showErrorAlert = true showErrorAlert = true
} catch { } catch {
errorMessage = "Error removing labels" errorMessage = "Fehler beim Entfernen der Labels"
showErrorAlert = true showErrorAlert = true
} }
isLoading = false isLoading = false
calculatePages()
} }
@MainActor @MainActor
@ -136,36 +78,9 @@ class BookmarkLabelsViewModel {
} else { } else {
await addLabel(to: bookmarkId, label: label) await addLabel(to: bookmarkId, label: label)
} }
calculatePages()
} }
func updateLabels(_ labels: [String]) { func updateLabels(_ labels: [String]) {
currentLabels = labels currentLabels = labels
} }
private func calculatePages() {
let pageSize = Constants.Labels.pageSize
// Calculate pages for all labels
if allLabels.count <= pageSize {
labelPages = [allLabels]
} else {
// Normal pagination for larger datasets
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
}
}
// Calculate pages for filtered labels (search results or available labels)
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
if labelsToShow.count <= pageSize {
availableLabelPages = [labelsToShow]
} else {
// Normal pagination for larger datasets
availableLabelPages = stride(from: 0, to: labelsToShow.count, by: pageSize).map {
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
}
}
}
} }

View File

@ -13,46 +13,19 @@ struct BookmarkCardView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottomTrailing) { 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(R.image.placeholder.name) Image(R.image.placeholder.name)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(height: 120) .frame(height: 120)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
ZStack {
Circle()
.fill(Color(.systemBackground))
.frame(width: 36, height: 36)
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
.frame(width: 32, height: 32)
Circle()
.trim(from: 0, to: CGFloat(bookmark.readProgress) / 100)
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 32, height: 32)
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text("\(bookmark.readProgress)")
.font(.caption2)
.bold()
Text("%")
.font(.system(size: 8))
.baselineOffset(2)
}
}
.padding(8)
}
} }
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(bookmark.title) Text(bookmark.title)
@ -64,7 +37,7 @@ struct BookmarkCardView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
// Published date // Veröffentlichungsdatum
if let publishedDate = formattedPublishedDate { if let publishedDate = formattedPublishedDate {
HStack { HStack {
Label(publishedDate, systemImage: "calendar") Label(publishedDate, systemImage: "calendar")
@ -85,7 +58,8 @@ struct BookmarkCardView: View {
} }
} }
HStack { HStack {
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Seite") + " öffnen", systemImage: "safari")
.onTapGesture { .onTapGesture {
SafariUtil.openInSafari(url: bookmark.url) SafariUtil.openInSafari(url: bookmark.url)
} }
@ -94,6 +68,12 @@ struct BookmarkCardView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
// Progress Bar für Lesefortschritt
if bookmark.readProgress > 0 {
ProgressView(value: Double(bookmark.readProgress), total: 100)
.progressViewStyle(LinearProgressViewStyle())
.frame(height: 4)
}
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 12) .padding(.bottom, 12)
@ -102,20 +82,20 @@ struct BookmarkCardView: View {
.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: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) { Button("Löschen", role: .destructive) {
onDelete(bookmark) onDelete(bookmark)
} }
.tint(.red) .tint(.red)
} }
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
// Archive (left) // Archivieren (links)
Button { Button {
onArchive(bookmark) onArchive(bookmark)
} label: { } label: {
if currentState == .archived { if currentState == .archived {
Label("Restore", systemImage: "tray.and.arrow.up") Label("Wiederherstellen", systemImage: "tray.and.arrow.up")
} else { } else {
Label("Archive", systemImage: "archivebox") Label("Archivieren", systemImage: "archivebox")
} }
} }
.tint(currentState == .archived ? .blue : .orange) .tint(currentState == .archived ? .blue : .orange)
@ -123,7 +103,7 @@ struct BookmarkCardView: View {
Button { Button {
onToggleFavorite(bookmark) onToggleFavorite(bookmark)
} label: { } label: {
Label(bookmark.isMarked ? "Remove" : "Favorite", Label(bookmark.isMarked ? "Entfernen" : "Favorit",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill") systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
} }
.tint(bookmark.isMarked ? .gray : .pink) .tint(bookmark.isMarked ? .gray : .pink)
@ -147,7 +127,7 @@ struct BookmarkCardView: View {
formatter.locale = Locale(identifier: "en_US_POSIX") 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 // Fallback ohne Millisekunden
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" 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
@ -162,42 +142,42 @@ struct BookmarkCardView: View {
let now = Date() let now = Date()
let calendar = Calendar.current let calendar = Calendar.current
// Today // Heute
if calendar.isDateInToday(date) { if calendar.isDateInToday(date) {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.timeStyle = .short formatter.timeStyle = .short
return "Today, \(formatter.string(from: date))" return "Heute, \(formatter.string(from: date))"
} }
// Yesterday // Gestern
if calendar.isDateInYesterday(date) { if calendar.isDateInYesterday(date) {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.timeStyle = .short formatter.timeStyle = .short
return "Yesterday, \(formatter.string(from: date))" return "Gestern, \(formatter.string(from: date))"
} }
// This week // Diese Woche
if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) { if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "EEEE, HH:mm" formatter.dateFormat = "EEEE, HH:mm"
return formatter.string(from: date) return formatter.string(from: date)
} }
// This year // Dieses Jahr
if calendar.isDate(date, equalTo: now, toGranularity: .year) { if calendar.isDate(date, equalTo: now, toGranularity: .year) {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "d. MMM, HH:mm" formatter.dateFormat = "d. MMM, HH:mm"
return formatter.string(from: date) return formatter.string(from: date)
} }
// Other years // Andere Jahre
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "d. MMM yyyy" formatter.dateFormat = "d. MMM yyyy"
return formatter.string(from: date) return formatter.string(from: date)
} }
private var imageURL: URL? { private var imageURL: URL? {
// Prioritize image, then thumbnail, then icon // Bevorzuge image, dann thumbnail, dann 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 { } else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
@ -223,12 +203,3 @@ struct IconBadge: View {
} }
} }
#Preview {
BookmarkCardView(bookmark: .mock, currentState: .all) { _ in
} onDelete: { _ in
} onToggleFavorite: { _ in
}
}

View File

@ -6,13 +6,12 @@ struct BookmarksView: View {
// MARK: States // MARK: States
@State private var viewModel: BookmarksViewModel @State private var viewModel = BookmarksViewModel()
@State private var showingAddBookmark = false @State private var showingAddBookmark = false
@State private var selectedBookmarkId: String? @State private var selectedBookmarkId: String?
@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]
@ -20,49 +19,23 @@ struct BookmarksView: View {
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
let tag: String? let tag: String?
// MARK: Initializer
init(state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
self.state = state
self.type = type
self._selectedBookmark = selectedBookmark
self.tag = tag
}
// MARK: Environments // MARK: Environments
@Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass @Environment(\.verticalSizeClass) var verticalSizeClass
// MARK: Initializer
init(viewModel: BookmarksViewModel = .init(), state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
self.state = state
self.type = type
self._selectedBookmark = selectedBookmark
self.tag = tag
self.viewModel = viewModel
}
var body: some View { var body: some View {
ZStack { ZStack {
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true { if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
VStack(spacing: 20) { ProgressView("Lade \(state.displayName)...")
Spacer()
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.3)
.tint(.accentColor)
VStack(spacing: 8) {
Text("Loading \(state.displayName)")
.font(.headline)
.foregroundColor(.primary)
Text("Please wait while we fetch your bookmarks...")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 40)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(R.color.bookmark_list_bg))
} else { } else {
List { List {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
@ -89,7 +62,9 @@ struct BookmarksView: View {
} }
}, },
onDelete: { bookmark in onDelete: { bookmark in
bookmarkToDelete = bookmark Task {
await viewModel.deleteBookmark(bookmark: bookmark)
}
}, },
onToggleFavorite: { bookmark in onToggleFavorite: { bookmark in
Task { Task {
@ -120,17 +95,17 @@ struct BookmarksView: View {
.overlay { .overlay {
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading { if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading {
ContentUnavailableView( ContentUnavailableView(
"No bookmarks", "Keine Bookmarks",
systemImage: "bookmark", systemImage: "bookmark",
description: Text( description: Text(
"No bookmarks found in \(state.displayName.lowercased())." "Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden."
) )
) )
} }
} }
} }
// FAB Button - only show for "Unread" // FAB Button - nur bei "Ungelesen" anzeigen
if state == .unread || state == .all { if state == .unread || state == .all {
VStack { VStack {
Spacer() Spacer()
@ -172,18 +147,13 @@ struct BookmarksView: View {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle) AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
} }
) )
.alert(item: $bookmarkToDelete) { bookmark in /*.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
Alert( Button("OK", role: .cancel) {
title: Text("Delete Bookmark"), viewModel.errorMessage = nil
message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."), }
primaryButton: .destructive(Text("Delete")) { } message: {
Task { Text(viewModel.errorMessage ?? "")
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)
@ -193,21 +163,14 @@ struct BookmarksView: View {
// Refresh bookmarks when sheet is dismissed // Refresh bookmarks when sheet is dismissed
if oldValue && !newValue { if oldValue && !newValue {
Task { Task {
// Wait a bit for the server to process the new bookmark await viewModel.loadBookmarks(state: state, type: type)
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
await viewModel.refreshBookmarks()
} }
} }
} }
} }
} }
#Preview { // String Identifiable Extension für navigationDestination
BookmarksView( extension String: Identifiable {
viewModel: .init(MockUseCaseFactory()), public var id: String { self }
state: .archived,
type: [.article],
selectedBookmark: .constant(nil),
tag: nil)
} }

View File

@ -4,9 +4,9 @@ import SwiftUI
@Observable @Observable
class BookmarksViewModel { class BookmarksViewModel {
private let getBooksmarksUseCase: PGetBookmarksUseCase private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBookmarksUseCase()
private let updateBookmarkUseCase: PUpdateBookmarkUseCase private let updateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase()
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase private let deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase()
var bookmarks: BookmarksPage? var bookmarks: BookmarksPage?
var isLoading = false var isLoading = false
@ -32,11 +32,7 @@ class BookmarksViewModel {
} }
} }
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { init() {
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
setupNotificationObserver() setupNotificationObserver()
} }
@ -61,6 +57,8 @@ class BookmarksViewModel {
self.shareTitle = userInfo["title"] as? String ?? "" self.shareTitle = userInfo["title"] as? String ?? ""
self.showingAddBookmarkFromShare = true self.showingAddBookmarkFromShare = true
} }
print("Received share notification - URL: \(url)")
} }
private func throttleSearch() { private func throttleSearch() {
@ -85,8 +83,8 @@ class BookmarksViewModel {
currentType = type currentType = type
currentTag = tag currentTag = tag
offset = 0 offset = 0 // Offset zurücksetzen
hasMoreData = true hasMoreData = true // Pagination zurücksetzen
do { do {
let newBookmarks = try await getBooksmarksUseCase.execute( let newBookmarks = try await getBooksmarksUseCase.execute(
@ -98,9 +96,9 @@ class BookmarksViewModel {
tag: tag tag: tag
) )
bookmarks = newBookmarks bookmarks = newBookmarks
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // Prüfen, ob weitere Daten verfügbar sind
} catch { } catch {
errorMessage = "Error loading bookmarks" errorMessage = "Fehler beim Laden der Bookmarks"
bookmarks = nil bookmarks = nil
} }
@ -109,24 +107,23 @@ class BookmarksViewModel {
@MainActor @MainActor
func loadMoreBookmarks() async { func loadMoreBookmarks() async {
guard !isLoading && hasMoreData else { return } // prevent multiple loads guard !isLoading && hasMoreData else { return } // Verhindern, dass mehrfach geladen wird
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
do { do {
offset += limit // inc. offset offset += limit // Offset erhöhen
let newBookmarks = try await getBooksmarksUseCase.execute( let newBookmarks = try await getBooksmarksUseCase.execute(
state: currentState, state: currentState,
limit: limit, limit: limit,
offset: offset, offset: offset,
search: nil,
type: currentType, type: currentType,
tag: currentTag) tag: currentTag)
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
} catch { } catch {
errorMessage = "Error loading more bookmarks" errorMessage = "Fehler beim Nachladen der Bookmarks"
} }
isLoading = false isLoading = false
@ -145,10 +142,11 @@ class BookmarksViewModel {
isArchived: !bookmark.isArchived isArchived: !bookmark.isArchived
) )
// Liste aktualisieren
await loadBookmarks(state: currentState) await loadBookmarks(state: currentState)
} catch { } catch {
errorMessage = "Error archiving bookmark" errorMessage = "Fehler beim Archivieren des Bookmarks"
} }
} }
@ -160,21 +158,26 @@ class BookmarksViewModel {
isMarked: !bookmark.isMarked isMarked: !bookmark.isMarked
) )
// Liste aktualisieren
await loadBookmarks(state: currentState) await loadBookmarks(state: currentState)
} catch { } catch {
errorMessage = "Error marking bookmark" errorMessage = "Fehler beim Markieren des Bookmarks"
} }
} }
@MainActor @MainActor
func deleteBookmark(bookmark: Bookmark) async { func deleteBookmark(bookmark: Bookmark) async {
do { do {
// Echtes Löschen über API statt nur als gelöscht markieren
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id) try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
// Lokal aus der Liste entfernen (optimistische Update)
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id } bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
} catch { } catch {
errorMessage = "Error deleting bookmark" errorMessage = "Fehler beim Löschen des Bookmarks"
// Bei Fehler die Liste neu laden, um konsistenten Zustand zu haben
await loadBookmarks(state: currentState) await loadBookmarks(state: currentState)
} }
} }

View File

@ -1,18 +0,0 @@
//
// Constants.swift
// readeck
//
// Created by Ilyas Hallak on 21.07.25.
//
// SPDX-License-Identifier: MIT
//
// This file is part of the readeck project and is licensed under the MIT License.
//
import Foundation
struct Constants {
struct Labels {
static let pageSize = 12
}
}

View File

@ -1,21 +0,0 @@
//
// CustomTextFieldStyle.swift
// readeck
//
// Created by Ilyas Hallak on 02.08.25.
//
import SwiftUI
struct CustomTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(.systemGray4), lineWidth: 1)
)
}
}

View File

@ -1,85 +0,0 @@
//
// RButton.swift
// readeck
//
// Created by Ilyas Hallak on 21.07.25.
//
// SPDX-License-Identifier: MIT
//
// This file is part of the readeck project and is licensed under the MIT License.
//
import SwiftUI
struct RButton<Label: View>: View {
let action: () -> Void
let isLoading: Bool
let isDisabled: Bool
let icon: String?
let label: () -> Label
init(isLoading: Bool = false, isDisabled: Bool = false, icon: String? = nil, action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) {
self.action = action
self.isLoading = isLoading
self.isDisabled = isDisabled
self.icon = icon
self.label = label
}
var body: some View {
Button(action: {
if !isLoading && !isDisabled {
action()
}
}) {
HStack {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
if let icon = icon {
Image(systemName: icon)
}
label()
}
.font(.title3.bold())
.frame(maxHeight: 60)
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
)
}
.buttonStyle(.bordered)
.disabled(isLoading || isDisabled)
}
}
#Preview {
Group {
RButton(isLoading: false, isDisabled: false, icon: "star.fill", action: {}) {
Text("Favorite")
.foregroundColor(.yellow)
}
.padding()
.preferredColorScheme(.light)
RButton(isLoading: true, isDisabled: false, action: {}) {
Text("Loading...")
}
.padding()
.preferredColorScheme(.dark)
RButton(isLoading: false, isDisabled: true, icon: nil, action: {}) {
Text("Disabled")
}
.padding()
.preferredColorScheme(.dark)
RButton(isLoading: false, isDisabled: false, icon: nil, action: {}) {
Text("No Icon")
}
.padding()
.preferredColorScheme(.light)
}
}

View File

@ -1,212 +0,0 @@
import SwiftUI
enum AddBookmarkFieldFocus {
case url
case labels
case title
}
struct FocusModifier: ViewModifier {
let focusBinding: FocusState<AddBookmarkFieldFocus?>.Binding?
let field: AddBookmarkFieldFocus
func body(content: Content) -> some View {
if let binding = focusBinding {
content.focused(binding, equals: field)
} else {
content
}
}
}
struct TagManagementView: View {
// MARK: - Properties
let allLabels: [BookmarkLabel]
let selectedLabelsSet: Set<String>
let searchText: Binding<String>
let isLabelsLoading: Bool
let availableLabelPages: [[BookmarkLabel]]
let filteredLabels: [BookmarkLabel]
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
// MARK: - Callbacks
let onAddCustomTag: () -> Void
let onToggleLabel: (String) -> Void
let onRemoveLabel: (String) -> Void
// MARK: - Initialization
init(
allLabels: [BookmarkLabel],
selectedLabels: Set<String>,
searchText: Binding<String>,
isLabelsLoading: Bool,
availableLabelPages: [[BookmarkLabel]],
filteredLabels: [BookmarkLabel],
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
onAddCustomTag: @escaping () -> Void,
onToggleLabel: @escaping (String) -> Void,
onRemoveLabel: @escaping (String) -> Void
) {
self.allLabels = allLabels
self.selectedLabelsSet = selectedLabels
self.searchText = searchText
self.isLabelsLoading = isLabelsLoading
self.availableLabelPages = availableLabelPages
self.filteredLabels = filteredLabels
self.searchFieldFocus = searchFieldFocus
self.onAddCustomTag = onAddCustomTag
self.onToggleLabel = onToggleLabel
self.onRemoveLabel = onRemoveLabel
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
searchField
customTagSuggestion
availableLabels
selectedLabels
}
}
// MARK: - View Components
@ViewBuilder
private var searchField: some View {
TextField("Search or add new tag...", text: searchText)
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.default)
.autocorrectionDisabled(true)
.onSubmit {
onAddCustomTag()
}
.modifier(FocusModifier(focusBinding: searchFieldFocus, field: .labels))
}
@ViewBuilder
private var customTagSuggestion: some View {
if !searchText.wrappedValue.isEmpty &&
!allLabels.contains(where: { $0.name.lowercased() == searchText.wrappedValue.lowercased() }) &&
!selectedLabelsSet.contains(searchText.wrappedValue) {
HStack {
Text("Add new tag:")
.font(.caption)
.foregroundColor(.secondary)
Text(searchText.wrappedValue)
.font(.caption)
.fontWeight(.medium)
Spacer()
Button(action: onAddCustomTag) {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill")
.font(.caption)
Text("Add")
.font(.caption)
.fontWeight(.medium)
}
}
.foregroundColor(.accentColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(Color.accentColor.opacity(0.1))
.cornerRadius(10)
}
}
@ViewBuilder
private var availableLabels: some View {
if !allLabels.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(searchText.wrappedValue.isEmpty ? "Available tags" : "Search results")
.font(.subheadline)
.fontWeight(.medium)
if !searchText.wrappedValue.isEmpty {
Text("(\(filteredLabels.count) found)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
if isLabelsLoading {
ProgressView()
.scaleEffect(0.8)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
} else if availableLabelPages.isEmpty {
VStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
.foregroundColor(.green)
Text("All tags selected")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
labelsTabView
}
}
.padding(.top, 8)
}
}
@ViewBuilder
private var labelsTabView: some View {
TabView {
ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(labelsPage, id: \.id) { label in
UnifiedLabelChip(
label: label.name,
isSelected: selectedLabelsSet.contains(label.name),
isRemovable: false,
onTap: {
onToggleLabel(label.name)
}
)
}
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.horizontal)
}
}
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
.frame(height: 180)
.padding(.top, -20)
}
@ViewBuilder
private var selectedLabels: some View {
if !selectedLabelsSet.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Selected tags")
.font(.subheadline)
.fontWeight(.medium)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(Array(selectedLabelsSet), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: false,
isRemovable: true,
onTap: {
// No action for selected labels
},
onRemove: {
onRemoveLabel(label)
}
)
}
}
}
.padding(.top, 8)
}
}
}

View File

@ -1,97 +0,0 @@
//
// UnifiedLabelChip.swift
// readeck
//
// Created by Ilyas Hallak on 21.07.25.
//
// SPDX-License-Identifier: MIT
//
// This file is part of the readeck project and is licensed under the MIT License.
//
import SwiftUI
struct UnifiedLabelChip: View {
let label: String
let isSelected: Bool
let isRemovable: Bool
let onTap: () -> Void
let onRemove: (() -> Void)?
init(label: String, isSelected: Bool = false, isRemovable: Bool = false, onTap: @escaping () -> Void, onRemove: (() -> Void)? = nil) {
self.label = label
self.isSelected = isSelected
self.isRemovable = isRemovable
self.onTap = onTap
self.onRemove = onRemove
}
var body: some View {
Button(action: onTap) {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(isSelected ? .white : .primary)
.lineLimit(1)
.truncationMode(.tail)
if isRemovable, let onRemove = onRemove {
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundColor(isSelected ? .white.opacity(0.8) : .secondary)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(minHeight: 32)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isSelected ? Color.accentColor : Color.accentColor.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.accentColor.opacity(0.4), lineWidth: 1)
)
)
}
.buttonStyle(PlainButtonStyle())
}
}
#Preview {
VStack(spacing: 16) {
UnifiedLabelChip(
label: "Sample Label",
isSelected: false,
isRemovable: false,
onTap: {}
)
UnifiedLabelChip(
label: "Selected Label",
isSelected: true,
isRemovable: false,
onTap: {}
)
UnifiedLabelChip(
label: "Removable Label",
isSelected: false,
isRemovable: true,
onTap: {},
onRemove: {}
)
UnifiedLabelChip(
label: "Selected & Removable",
isSelected: true,
isRemovable: true,
onTap: {},
onRemove: {}
)
}
.padding()
}

View File

@ -5,7 +5,6 @@ struct WebView: UIViewRepresentable {
let htmlContent: String let htmlContent: String
let settings: Settings let settings: Settings
let onHeightChange: (CGFloat) -> Void let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView { func makeUIView(context: Context) -> WKWebView {
@ -17,9 +16,7 @@ struct WebView: UIViewRepresentable {
// Message Handler hier einmalig hinzufügen // Message Handler hier einmalig hinzufügen
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate") webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
context.coordinator.onHeightChange = onHeightChange context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
return webView return webView
} }
@ -27,7 +24,6 @@ struct WebView: UIViewRepresentable {
func updateUIView(_ webView: WKWebView, context: Context) { func updateUIView(_ webView: WKWebView, context: Context) {
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration // Nur den HTML-Inhalt laden, keine Handler-Konfiguration
context.coordinator.onHeightChange = onHeightChange context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
let isDarkMode = colorScheme == .dark let isDarkMode = colorScheme == .dark
@ -220,19 +216,14 @@ struct WebView: UIViewRepresentable {
} }
window.addEventListener('load', updateHeight); window.addEventListener('load', updateHeight);
setTimeout(updateHeight, 100);
setTimeout(updateHeight, 500); setTimeout(updateHeight, 500);
setTimeout(updateHeight, 1000);
// Höhe bei Bild-Ladevorgängen aktualisieren // Höhe bei Bild-Ladevorgängen aktualisieren
document.querySelectorAll('img').forEach(img => { document.querySelectorAll('img').forEach(img => {
img.addEventListener('load', updateHeight); img.addEventListener('load', updateHeight);
}); });
// Scroll progress reporting
window.addEventListener('scroll', function() {
var scrollTop = window.scrollY || document.documentElement.scrollTop;
var docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
var progress = docHeight > 0 ? scrollTop / docHeight : 0;
window.webkit.messageHandlers.scrollProgress.postMessage(progress);
});
</script> </script>
</body> </body>
</html> </html>
@ -269,8 +260,6 @@ struct WebView: UIViewRepresentable {
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
var onHeightChange: ((CGFloat) -> Void)? var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)?
var hasHeightUpdate: Bool = false
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated { if navigationAction.navigationType == .linkActivated {
@ -286,16 +275,12 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "heightUpdate", let height = message.body as? CGFloat { if message.name == "heightUpdate", let height = message.body as? CGFloat {
DispatchQueue.main.async { DispatchQueue.main.async {
if self.hasHeightUpdate == false { self.onHeightChange?(height)
self.onHeightChange?(height)
self.hasHeightUpdate = true
}
}
}
if message.name == "scrollProgress", let progress = message.body as? Double {
DispatchQueue.main.async {
self.onScroll?(progress)
} }
} }
} }
deinit {
// Der Message Handler wird automatisch mit der WebView entfernt
}
} }

View File

@ -0,0 +1,103 @@
import Foundation
protocol UseCaseFactory {
func makeLoginUseCase() -> LoginUseCase
func makeGetBookmarksUseCase() -> GetBookmarksUseCase
func makeGetBookmarkUseCase() -> GetBookmarkUseCase
func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase
func makeSaveSettingsUseCase() -> SaveSettingsUseCase
func makeLoadSettingsUseCase() -> LoadSettingsUseCase
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase
func makeLogoutUseCase() -> LogoutUseCase
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> GetLabelsUseCase
func makeAddTextToSpeechQueueUseCase() -> AddTextToSpeechQueueUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
private let tokenProvider = CoreDataTokenProvider()
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
private let settingsRepository: PSettingsRepository = SettingsRepository()
static let shared = DefaultUseCaseFactory()
private init() {}
func makeLoginUseCase() -> LoginUseCase {
LoginUseCase(repository: authRepository)
}
func makeGetBookmarksUseCase() -> GetBookmarksUseCase {
GetBookmarksUseCase(repository: bookmarksRepository)
}
func makeGetBookmarkUseCase() -> GetBookmarkUseCase {
GetBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase {
GetBookmarkArticleUseCase(repository: bookmarksRepository)
}
func makeSaveSettingsUseCase() -> SaveSettingsUseCase {
SaveSettingsUseCase(settingsRepository: settingsRepository)
}
func makeLoadSettingsUseCase() -> LoadSettingsUseCase {
LoadSettingsUseCase(authRepository: authRepository)
}
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase {
return UpdateBookmarkUseCase(repository: bookmarksRepository)
}
func makeLogoutUseCase() -> LogoutUseCase {
return LogoutUseCase()
}
// Nicht mehr nötig - Token wird automatisch geladen
func refreshConfiguration() async {
// Optional: Cache löschen falls nötig
}
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase {
return DeleteBookmarkUseCase(repository: bookmarksRepository)
}
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase {
return CreateBookmarkUseCase(repository: bookmarksRepository)
}
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase {
return SearchBookmarksUseCase(repository: bookmarksRepository)
}
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase {
return SaveServerSettingsUseCase(repository: SettingsRepository())
}
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase {
return AddLabelsToBookmarkUseCase(repository: bookmarksRepository)
}
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase {
return RemoveLabelsFromBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetLabelsUseCase() -> GetLabelsUseCase {
let api = API(tokenProvider: CoreDataTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return GetLabelsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> AddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase()
}
}

View File

@ -1,10 +0,0 @@
//
// File.swift
// readeck
//
// Created by Ilyas Hallak on 18.07.25.
//
extension String: @retroactive Identifiable {
public var id: String { self }
}

View File

@ -1,100 +0,0 @@
import Foundation
protocol UseCaseFactory {
func makeLoginUseCase() -> PLoginUseCase
func makeGetBookmarksUseCase() -> PGetBookmarksUseCase
func makeGetBookmarkUseCase() -> PGetBookmarkUseCase
func makeGetBookmarkArticleUseCase() -> PGetBookmarkArticleUseCase
func makeSaveSettingsUseCase() -> PSaveSettingsUseCase
func makeLoadSettingsUseCase() -> PLoadSettingsUseCase
func makeUpdateBookmarkUseCase() -> PUpdateBookmarkUseCase
func makeDeleteBookmarkUseCase() -> PDeleteBookmarkUseCase
func makeCreateBookmarkUseCase() -> PCreateBookmarkUseCase
func makeLogoutUseCase() -> PLogoutUseCase
func makeSearchBookmarksUseCase() -> PSearchBookmarksUseCase
func makeSaveServerSettingsUseCase() -> PSaveServerSettingsUseCase
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> PGetLabelsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
private let tokenProvider = CoreDataTokenProvider()
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
private let settingsRepository: PSettingsRepository = SettingsRepository()
static let shared = DefaultUseCaseFactory()
private init() {}
func makeLoginUseCase() -> PLoginUseCase {
LoginUseCase(repository: authRepository)
}
func makeGetBookmarksUseCase() -> PGetBookmarksUseCase {
GetBookmarksUseCase(repository: bookmarksRepository)
}
func makeGetBookmarkUseCase() -> PGetBookmarkUseCase {
GetBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetBookmarkArticleUseCase() -> PGetBookmarkArticleUseCase {
GetBookmarkArticleUseCase(repository: bookmarksRepository)
}
func makeSaveSettingsUseCase() -> PSaveSettingsUseCase {
SaveSettingsUseCase(settingsRepository: settingsRepository)
}
func makeLoadSettingsUseCase() -> PLoadSettingsUseCase {
LoadSettingsUseCase(authRepository: authRepository)
}
func makeUpdateBookmarkUseCase() -> PUpdateBookmarkUseCase {
return UpdateBookmarkUseCase(repository: bookmarksRepository)
}
func makeLogoutUseCase() -> PLogoutUseCase {
return LogoutUseCase()
}
func makeDeleteBookmarkUseCase() -> PDeleteBookmarkUseCase {
return DeleteBookmarkUseCase(repository: bookmarksRepository)
}
func makeCreateBookmarkUseCase() -> PCreateBookmarkUseCase {
return CreateBookmarkUseCase(repository: bookmarksRepository)
}
func makeSearchBookmarksUseCase() -> PSearchBookmarksUseCase {
return SearchBookmarksUseCase(repository: bookmarksRepository)
}
func makeSaveServerSettingsUseCase() -> PSaveServerSettingsUseCase {
return SaveServerSettingsUseCase(repository: SettingsRepository())
}
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase {
return AddLabelsToBookmarkUseCase(repository: bookmarksRepository)
}
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase {
return RemoveLabelsFromBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetLabelsUseCase() -> PGetLabelsUseCase {
let api = API(tokenProvider: CoreDataTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return GetLabelsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase()
}
}

View File

@ -1,188 +0,0 @@
//
// MockUseCaseFactory.swift
// readeck
//
// Created by Ilyas Hallak on 18.07.25.
//
import Foundation
class MockUseCaseFactory: UseCaseFactory {
func makeLoginUseCase() -> any PLoginUseCase {
MockLoginUserCase()
}
func makeGetBookmarksUseCase() -> any PGetBookmarksUseCase {
MockGetBookmarksUseCase()
}
func makeGetBookmarkUseCase() -> any PGetBookmarkUseCase {
MockGetBookmarkUseCase()
}
func makeGetBookmarkArticleUseCase() -> any PGetBookmarkArticleUseCase {
MockGetBookmarkArticleUseCase()
}
func makeSaveSettingsUseCase() -> any PSaveSettingsUseCase {
MockSaveSettingsUseCase()
}
func makeLoadSettingsUseCase() -> any PLoadSettingsUseCase {
MockLoadSettingsUseCase()
}
func makeUpdateBookmarkUseCase() -> any PUpdateBookmarkUseCase {
MockUpdateBookmarkUseCase()
}
func makeDeleteBookmarkUseCase() -> any PDeleteBookmarkUseCase {
MockDeleteBookmarkUseCase()
}
func makeCreateBookmarkUseCase() -> any PCreateBookmarkUseCase {
MockCreateBookmarkUseCase()
}
func makeLogoutUseCase() -> any PLogoutUseCase {
MockLogoutUseCase()
}
func makeSearchBookmarksUseCase() -> any PSearchBookmarksUseCase {
MockSearchBookmarksUseCase()
}
func makeSaveServerSettingsUseCase() -> any PSaveServerSettingsUseCase {
MockSaveServerSettingsUseCase()
}
func makeAddLabelsToBookmarkUseCase() -> any PAddLabelsToBookmarkUseCase {
MockAddLabelsToBookmarkUseCase()
}
func makeRemoveLabelsFromBookmarkUseCase() -> any PRemoveLabelsFromBookmarkUseCase {
MockRemoveLabelsFromBookmarkUseCase()
}
func makeGetLabelsUseCase() -> any PGetLabelsUseCase {
MockGetLabelsUseCase()
}
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
MockAddTextToSpeechQueueUseCase()
}
}
// MARK: Mocked Use Cases
class MockLoginUserCase: PLoginUseCase {
func execute(endpoint: String, username: String, password: String) async throws -> User {
return User(id: "123", token: "abc")
}
}
class MockLogoutUseCase: PLogoutUseCase {
func execute() async throws {}
}
class MockCreateBookmarkUseCase: PCreateBookmarkUseCase {
func execute(createRequest: CreateBookmarkRequest) async throws -> String { "mock-bookmark-id" }
func createFromURL(_ url: String) async throws -> String { "mock-bookmark-id" }
func createFromURLWithTitle(_ url: String, title: String) async throws -> String { "mock-bookmark-id" }
func createFromURLWithLabels(_ url: String, labels: [String]) async throws -> String { "mock-bookmark-id" }
func createFromClipboard() async throws -> String? { "mock-bookmark-id" }
}
class MockGetLabelsUseCase: PGetLabelsUseCase {
func execute() async throws -> [BookmarkLabel] {
[BookmarkLabel(name: "Test", count: 1, href: "mock-href")]
}
}
class MockSearchBookmarksUseCase: PSearchBookmarksUseCase {
func execute(search: String) async throws -> BookmarksPage {
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)
}
}
class MockReadBookmarkUseCase: PReadBookmarkUseCase {
func execute(bookmarkDetail: BookmarkDetail) {}
}
class MockGetBookmarksUseCase: PGetBookmarksUseCase {
func execute(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage {
BookmarksPage(bookmarks: [
Bookmark.mock
], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)
}
}
class MockUpdateBookmarkUseCase: PUpdateBookmarkUseCase {
func execute(bookmarkId: String, updateRequest: BookmarkUpdateRequest) async throws {}
func toggleArchive(bookmarkId: String, isArchived: Bool) async throws {}
func toggleFavorite(bookmarkId: String, isMarked: Bool) async throws {}
func markAsDeleted(bookmarkId: String) async throws {}
func updateReadProgress(bookmarkId: String, progress: Int, anchor: String?) async throws {}
func updateTitle(bookmarkId: String, title: String) async throws {}
func updateLabels(bookmarkId: String, labels: [String]) async throws {}
func addLabels(bookmarkId: String, labels: [String]) async throws {}
func removeLabels(bookmarkId: String, labels: [String]) async throws {}
}
class MockSaveSettingsUseCase: PSaveSettingsUseCase {
func execute(endpoint: String, username: String, password: String) async throws {}
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws {}
func execute(token: String) async throws {}
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {}
func execute(enableTTS: Bool) async throws {}
func execute(theme: Theme) async throws {}
}
class MockGetBookmarkUseCase: PGetBookmarkUseCase {
func execute(id: String) async throws -> BookmarkDetail {
BookmarkDetail(id: "123", title: "Test", url: "https://www.google.com", description: "Test", siteName: "Test", authors: ["Test"], created: "2021-01-01", updated: "2021-01-01", wordCount: 100, readingTime: 100, hasArticle: true, isMarked: false, isArchived: false, labels: ["Test"], thumbnailUrl: "https://picsum.photos/30/30", imageUrl: "https://picsum.photos/400/400", lang: "en", readProgress: 0)
}
}
class MockLoadSettingsUseCase: PLoadSettingsUseCase {
func execute() async throws -> Settings? {
Settings(endpoint: "mock-endpoint", username: "mock-user", password: "mock-pw", token: "mock-token", fontFamily: .system, fontSize: .medium, hasFinishedSetup: true)
}
}
class MockDeleteBookmarkUseCase: PDeleteBookmarkUseCase {
func execute(bookmarkId: String) async throws {}
}
class MockGetBookmarkArticleUseCase: PGetBookmarkArticleUseCase {
func execute(id: String) async throws -> String {
let path = Bundle.main.path(forResource: "article", ofType: "html")
return try String(contentsOfFile: path!)
}
}
class MockAddLabelsToBookmarkUseCase: PAddLabelsToBookmarkUseCase {
func execute(bookmarkId: String, labels: [String]) async throws {}
func execute(bookmarkId: String, label: String) async throws {}
}
class MockRemoveLabelsFromBookmarkUseCase: PRemoveLabelsFromBookmarkUseCase {
func execute(bookmarkId: String, labels: [String]) async throws {}
func execute(bookmarkId: String, label: String) async throws {}
}
class MockSaveServerSettingsUseCase: PSaveServerSettingsUseCase {
func execute(endpoint: String, username: String, password: String, token: String) async throws {}
}
class MockAddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
func execute(bookmarkDetail: BookmarkDetail) {}
}
extension Bookmark {
static let mock: Bookmark = .init(
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
)
}

View File

@ -1,135 +0,0 @@
<section>
<h3 id=""><strong> </strong></h3><h2 id="hn.CeDL.why-swiftdata-should-be-isolated">Why SwiftData Should Be Isolated</h2><p>While <strong>SwiftData</strong> provides a smooth developer experience thanks to its macro-based integration and built-in support for <code>@Model</code>, <code>@Query</code>, and <code>@Environment(\.modelContext)</code>, it introduces a major architectural concern:<strong> tight coupling between persistence and the UI layer</strong>.</p><p>When you embed SwiftData directly into your views or view models, you violate clean architecture principles like <strong>separation of concerns</strong> and <strong>dependency inversion</strong>. This makes your code:</p><ul><li><strong>Hard to test:</strong> mocking SwiftData becomes complex or even impossible</li><li><strong>Difficult to swap:</strong> migrating to another persistence mechanism (e.g., in-memory storage for previews or tests) becomes painful</li><li><strong>Less maintainable:</strong> UI logic becomes tightly bound to storage details</li></ul><p>To preserve the <strong>testability</strong>, <strong>flexibility</strong>, and <strong>scalability</strong> of your app, its critical to <strong>isolate SwiftData behind an abstraction</strong>.</p>
<p>
In this tutorial, well focus on how to achieve this isolation by applying SOLID principles, with a special emphasis on the Dependency Inversion Principle. Well show how to decouple SwiftData from the view and the view model, making your app cleaner, safer, and future-proof, ensuring your app&#39;s scalability.
</p>
<blockquote>View the full source code on GitHub: <a href="https://github.com/belkhadir/SwiftDataApp/?ref=swiftorbit.io" rel="nofollow noopener noreferrer">https://github.com/belkhadir/SwiftDataApp/</a></blockquote><h2 id="hn.CeDL.defining-the-boundaries">Defining the Boundaries</h2><p>The example you&#39;ll see shortly is intentionally simple. The goal is clarity, so you can follow along easily and fully grasp the key concepts. But before diving into the code, let&#39;s understand what we mean by <strong>boundaries</strong>, as clearly defined by Uncle Bob (Robert C. Martin):</p>
<blockquote>
<p>“Those boundaries separate software elements from one another, and restrict those on one side from knowing about those on the other.”</p>
</blockquote>
<figure id=""><img src="https://readeck.mnk.any64.de/bm/3Z/3ZPaYQx6tgL2wG8ZMBFzdq/_resources/MVrFafkXKxjHRUR68PpRnp.png" alt="" loading="lazy" id="" width="838" height="658"/><figcaption id=""><span id="">Figure 1: Diagram from </span><i id=""><em id="">Clean Architecture</em></i><span id=""> by Robert C. Martin showing the separation between business rules and database access</span></figcaption></figure><p>In our app, when a user taps the “+” button, we add a new Person. The <strong>UI layer</strong> should neither know nor care about <strong>how or where</strong> the Person is saved. Its sole responsibility is straightforward: <strong>display a list of persons</strong>.</p><p>Using something like @Query directly within our SwiftUI views violates these boundaries. Doing so tightly couples the UI to the persistence mechanism (in our case, SwiftData). This breaks the fundamental principle of <strong>Single Responsibility</strong>, as our views now know too much specific detail about data storage and retrieval.</p><p>In the following sections, well show how to respect these boundaries by carefully isolating the persistence logic from the UI, ensuring each layer remains focused, clean, and maintainable.</p><h2 id="hn.CeDL.abstracting-the-persistence-layer">Abstracting the Persistence Layer</h2><p>First, lets clearly outline our requirements. Our app needs to perform three main actions:</p><ol><li><strong>Add a new person</strong></li><li><strong>Fetch all persons</strong></li><li><strong>Delete a specific person</strong></li></ol><p>To ensure these operations are not directly tied to any specific storage framework (like SwiftData), we encapsulate them inside a <strong>protocol</strong>. Well name this protocol PersonDataStore:</p><pre><code>public protocol PersonDataStore {
func fetchAll() throws -&gt; [Person]
func save(_ person: Person) throws
func delete(_ person: Person) throws
}
</code></pre>
<p>Next, we define our primary <strong>entity</strong>, Person, as a simple struct. Notice it doesnt depend on SwiftData or any other framework:</p><pre><code>public struct Person: Identifiable {
public var id: UUID = UUID()
public let name: String
public init(name: String) {
self.name = name
}
}
</code></pre>
<p>These definitions (PersonDataStore and Person) become the core of our domain, forming a stable abstraction for persistence that other layers can depend upon.</p><h2 id="hn.CeDL.implementing-swiftdata-in-the-infra-layer">Implementing SwiftData in the Infra Layer</h2><p>Now that we have our Domain layer clearly defined, lets implement the persistence logic using <strong>SwiftData</strong>. Well encapsulate the concrete implementation in a dedicated framework called SwiftDataInfra.</p><h3 id="hn.CeDL.defining-the-local-model">Defining the Local Model</h3><p>First, we define a local model called LocalePerson. You might wonder why we create a separate model rather than directly using our domain Person entity. The reason is simple:</p><ul><li>LocalePerson serves as a <strong>SwiftData-specific </strong>model that interacts directly with the SwiftData framework.</li><li>It remains <strong>internal</strong> and <strong>isolated</strong> within the infrastructure layer, never exposed to the outside layers, preserving architectural boundaries.</li></ul><pre><code>import SwiftData
@Model
final class LocalePerson: Identifiable {
@Attribute(.unique) var name: String
init(name: String) {
self.name = name
}
}
</code></pre>
<p>Note that we annotate it with <code>@Model</code> and specify <code>@Attribute(.unique)</code> on the name property, signaling to SwiftData that each persons name must be unique.</p><h3 id="hn.CeDL.implementing-the-persistence-logic">Implementing the Persistence Logic</h3><p>To implement persistence operations (fetch, save, delete), well use SwiftDatas ModelContext. Well inject this context directly into our infrastructure class (SwiftDataPersonDataStore) via constructor injection:</p><pre><code>import Foundation
import SwiftData
import SwiftDataDomain
public final class SwiftDataPersonDataStore {
private let modelContext: ModelContext
public init(modelContext: ModelContext) {
self.modelContext = modelContext
}
}
</code></pre>
<h3 id="hn.CeDL.conforming-to-persondatastore">Conforming to PersonDataStore</h3><p>Our infrastructure class will now conform to our domain protocol <code>PersonDataStore</code>. Heres how each operation is implemented:</p><p><strong>1. Fetching all persons:</strong></p><pre><code>public func fetchAll() throws -&gt; [Person] {
let request = FetchDescriptor&lt;LocalePerson&gt;(sortBy: [SortDescriptor(\.name)])
let results = try modelContext.fetch(request)
return results.map { Person(name: $0.name) }
}
</code></pre>
<ul><li>We use a <code>FetchDescriptor</code> to define our query, sorting persons by their name.</li><li>We map each <code>LocalePerson</code> (infra model) to a plain <code>Person</code> entity (domain model), maintaining isolation from SwiftData specifics.</li></ul><p><strong>2. Saving a person:</strong></p><pre><code>public func save(_ person: Person) throws {
let localPerson = LocalePerson(name: person.name)
modelContext.insert(localPerson)
try modelContext.save()
}
</code></pre>
<ul><li>We create a new <code>LocalePerson</code> instance.</li><li>We insert this instance into SwiftDatas context, then explicitly save the changes.</li></ul><p><strong>3. Deleting a person:</strong></p><pre><code>public func delete(_ person: Person) throws {
let request = FetchDescriptor&lt;LocalePerson&gt;(sortBy: [SortDescriptor(\.name)])
let results = try modelContext.fetch(request)
guard let localPerson = results.first else { return }
modelContext.delete(localPerson)
try modelContext.save()
}
</code></pre>
<ul><li>We fetch the corresponding LocalePerson.</li><li>We delete the fetched object and save the context.</li><li>(Note: For a robust production app, youd typically want to match using unique identifiers rather than just picking the first result.)</li></ul><h2 id="hn.CeDL.viewmodel-that-doesn%E2%80%99t-know-about-swiftdata">ViewModel That Doesnt Know About SwiftData</h2><p>Our ViewModel is placed in a separate framework called <strong>SwiftDataPresentation</strong>, which depends <strong>only</strong> on the Domain layer (SwiftDataDomain). Crucially, this ViewModel knows <strong>nothing</strong> about SwiftData specifics or any persistence details. Its sole responsibility is managing UI state and interactions, displaying persons when the view appears, and handling the addition or deletion of persons through user actions.</p><figure id=""><img src="https://readeck.mnk.any64.de/bm/3Z/3ZPaYQx6tgL2wG8ZMBFzdq/_resources/4vML2Vj2nCR6cC4T8B6nAo.png" alt="" loading="lazy" id="" width="1206" height="2622"/><figcaption id=""><span id="">SwiftUI list view displaying people added using a modular SwiftData architecture, with a clean decoupled ViewModel.</span></figcaption></figure><p>Heres the ViewModel implementation, highlighting dependency injection clearly:</p><pre><code>public final class PersonViewModel {
// Dependency injected through initializer
private let personDataStore: PersonDataStore
// UI state management using ViewState
public private(set) var viewState: ViewState&lt;[Person]&gt; = .idle
public init(personDataStore: PersonDataStore) {
self.personDataStore = personDataStore
}
}
</code></pre>
<h3 id="hn.CeDL.explanation-of-the-injection-and-usage">Explanation of the Injection and Usage</h3><ul><li><strong>Constructor Injection</strong>:<ul><li>The <code>PersonDataStore</code> is injected into the <code>PersonViewModel</code> through its initializer.</li></ul></li><ul><li>By depending only on the <code>PersonDataStore</code> protocol, the ViewModel remains <strong>agnostic</strong> about which persistence implementation its using (SwiftData, Core Data, or even an in-memory store for testing purposes).</li></ul><li><strong>How <code>PersonDataStore</code> is Used</strong>:<ul><li><strong>Loading Data (onAppear)</strong>:</li></ul></li></ul><pre><code>public func onAppear() {
viewState = .loaded(allPersons())
}
</code></pre>
<ul><ul><li><strong>Adding a New Person</strong>:</li></ul></ul><pre><code>public func addPerson(_ person: Person) {
perform { try personDataStore.save(person) }
}
</code></pre>
<p>The ViewModel delegates saving the new person to the injected store, without knowing how or where it happens.</p><ul><ul><li><strong>Deleting a Person</strong>:</li></ul></ul><pre><code>public func deletePerson(at offsets: IndexSet) {
switch viewState {
case .loaded(let people) where !people.isEmpty:
for index in offsets {
let person = people[index]
perform { try personDataStore.delete(person) }
}
default:
break
}
}
</code></pre>
<p>Similarly, deletion is entirely delegated to the injected store, keeping persistence details completely hidden from the ViewModel.</p><h2 id="hn.CeDL.composing-the-app-without-breaking-boundaries">Composing the App Without Breaking Boundaries</h2><p>Now that we&#39;ve built clearly defined layers, Domain, Infrastructure, and Presentation, it&#39;s time to tie everything together into our application. But there&#39;s one important rule: the way we compose our application <strong>shouldn&#39;t compromise our carefully crafted boundaries</strong>.</p><figure id=""><img src="https://readeck.mnk.any64.de/bm/3Z/3ZPaYQx6tgL2wG8ZMBFzdq/_resources/4vBMiXQ2JzCa5Miuce63wa.png" alt="" loading="lazy" id="" width="1280" height="758"/><figcaption id=""><i id=""><em id="">Clean architecture dependency graph for a SwiftUI app using SwiftData, showing separated App, Presentation, Domain, and Infra layers</em></i></figcaption></figure><h3 id="hn.CeDL.application-composition-swiftdataappapp">Application Composition (SwiftDataAppApp)</h3><p>Our application&#39;s entry point, SwiftDataAppApp, acts as the composition root. It has full knowledge of every module, enabling it to wire dependencies together without letting those details leak into the inner layers:</p><pre><code>import SwiftUI
import SwiftData
import SwiftDataInfra
import SwiftDataPresentation
@main
struct SwiftDataAppApp: App {
let container: ModelContainer
init() {
// Creating our SwiftData ModelContainer through a factory method.
do {
container = try SwiftDataInfraContainerFactory.makeContainer()
} catch {
fatalError(&#34;Failed to initialize ModelContainer: \(error)&#34;)
}
}
var body: some Scene {
WindowGroup {
// Constructing the view with dependencies injected.
ListPersonViewContructionView.construct(container: container)
}
}
}
</code></pre>
<h2 id="hn.CeDL.benefits-of-this-isolation">Benefits of This Isolation</h2><p>By encapsulating SwiftData logic within the Infrastructure layer and adhering strictly to the PersonDataStore protocol, weve achieved a powerful separation:</p><ul><li><strong>The Presentation Layer</strong> and <strong>Domain Layer</strong> remain entirely unaware of SwiftData.</li><li>Our code becomes significantly more <strong>testable</strong> and <strong>maintainable</strong>.</li><li>Were free to <strong>change or replace SwiftData</strong> without affecting the rest of the app.</li></ul><h2 id="hn.CeDL.references-and-further-reading"><strong> References and Further Reading</strong></h2><ul><li><strong>Clean Architecture (Robert C. Martin)</strong><br/><a href="https://books.apple.com/us/book/clean-architecture/id1315522850?ref=swiftorbit.io" rel="nofollow noopener noreferrer">https://books.apple.com/us/book/clean-architecture/id1315522850</a></li><li><strong>Essential Developer iOS Development &amp; Architecture Courses</strong><br/><a href="https://www.essentialdeveloper.com/?ref=swiftorbit.io" rel="nofollow noopener noreferrer">https://www.essentialdeveloper.com</a></li><li><strong>Apple Documentation: SwiftData</strong><br/><a href="https://developer.apple.com/documentation/SwiftData?ref=swiftorbit.io" rel="nofollow noopener noreferrer">https://developer.apple.com/documentation/SwiftData</a></li></ul>
</section>

View File

@ -2,38 +2,28 @@ import SwiftUI
struct LabelsView: View { struct LabelsView: View {
@State var viewModel = LabelsViewModel() @State var viewModel = LabelsViewModel()
@Binding var selectedTag: BookmarkLabel? @State private var selectedTag: String? = nil
@State private var selectedBookmark: Bookmark? = nil
init(viewModel: LabelsViewModel = LabelsViewModel(), selectedTag: Binding<BookmarkLabel?>) {
self.viewModel = viewModel
self._selectedTag = selectedTag
}
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
} else if let errorMessage = viewModel.errorMessage { } else if let errorMessage = viewModel.errorMessage {
Text("Error: \(errorMessage)") Text("Fehler: \(errorMessage)")
.foregroundColor(.red) .foregroundColor(.red)
} else { } else {
List { List {
ForEach(viewModel.labels, id: \.href) { label in ForEach(viewModel.labels, id: \.href) { label in
if UIDevice.isPhone { NavigationLink {
NavigationLink { BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name)
BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name) .navigationTitle("\(label.name) (\(label.count))")
.navigationTitle("\(label.name) (\(label.count))") } label: {
} label: { HStack {
ButtonLabel(label) Text(label.name)
} Spacer()
} else { Text("\(label.count)")
Button { .foregroundColor(.secondary)
selectedTag = nil
DispatchQueue.main.async {
selectedTag = label
}
} label: {
ButtonLabel(label)
} }
} }
} }
@ -46,14 +36,4 @@ struct LabelsView: View {
} }
} }
} }
@ViewBuilder
private func ButtonLabel(_ label: BookmarkLabel) -> some View {
HStack {
Text(label.name)
Spacer()
Text("\(label.count)")
.foregroundColor(.secondary)
}
}
} }

View File

@ -3,18 +3,11 @@ import Observation
@Observable @Observable
class LabelsViewModel { class LabelsViewModel {
private let getLabelsUseCase: PGetLabelsUseCase private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
var labels: [BookmarkLabel] = [] var labels: [BookmarkLabel] = []
var isLoading: Bool var isLoading = false
var errorMessage: String? var errorMessage: String? = nil
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared, labels: [BookmarkLabel] = [], isLoading: Bool = false, errorMessage: String? = nil) {
self.labels = labels
self.isLoading = isLoading
self.errorMessage = errorMessage
getLabelsUseCase = factory.makeGetLabelsUseCase()
}
@MainActor @MainActor
func loadLabels() async { func loadLabels() async {
@ -23,7 +16,7 @@ class LabelsViewModel {
do { do {
labels = try await getLabelsUseCase.execute() labels = try await getLabelsUseCase.execute()
} catch { } catch {
errorMessage = "Error loading labels" errorMessage = "Fehler beim Laden der Labels"
} }
isLoading = false isLoading = false
} }

View File

@ -14,13 +14,13 @@ enum BookmarkState: String, CaseIterable {
var displayName: String { var displayName: String {
switch self { switch self {
case .all: case .all:
return "All" return "Alle"
case .unread: case .unread:
return "Unread" return "Ungelesen"
case .favorite: case .favorite:
return "Favorites" return "Favoriten"
case .archived: case .archived:
return "Archive" return "Archiv"
} }
} }

View File

@ -12,7 +12,6 @@ struct PadSidebarView: View {
@State private var selectedBookmark: Bookmark? @State private var selectedBookmark: Bookmark?
@State private var selectedTag: BookmarkLabel? @State private var selectedTag: BookmarkLabel?
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags] private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
@ -54,11 +53,8 @@ struct PadSidebarView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg)) .listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg))
PlayerQueueResumeButton()
if appSettings.enableTTS { .padding(.top, 8)
PlayerQueueResumeButton()
.padding(.top, 8)
}
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(Color(R.color.menu_sidebar_bg)) .background(Color(R.color.menu_sidebar_bg))
@ -86,16 +82,7 @@ struct PadSidebarView: View {
case .pictures: case .pictures:
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark) BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
case .tags: case .tags:
NavigationStack { LabelsView()
LabelsView(selectedTag: $selectedTag)
.navigationDestination(item: $selectedTag) { label in
BookmarksView(state: .all, type: [], selectedBookmark: $selectedBookmark, tag: label.name)
.navigationTitle("\(label.name) (\(label.count))")
.onDisappear {
selectedTag = nil
}
}
}
} }
} }
.navigationTitle(selectedTab.label) .navigationTitle(selectedTab.label)
@ -103,7 +90,7 @@ struct PadSidebarView: View {
} detail: { } detail: {
if let bookmark = selectedBookmark, selectedTab != .settings { if let bookmark = selectedBookmark, selectedTab != .settings {
BookmarkDetailView(bookmarkId: bookmark.id) BookmarkDetailView(bookmarkId: bookmark.id)
} else if selectedTab == .settings { } else {
Text(selectedTab == .settings ? "" : "Select a bookmark or tag") Text(selectedTab == .settings ? "" : "Select a bookmark or tag")
.foregroundColor(.gray) .foregroundColor(.gray)
} }

View File

@ -12,9 +12,7 @@ struct PhoneTabView: View {
private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings] private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings]
@State private var selectedMoreTab: SidebarTab? = nil @State private var selectedMoreTab: SidebarTab? = nil
@State private var selectedTabIndex: Int = 1 @State private var selectedTabIndex: Int = 0
@EnvironmentObject var appSettings: AppSettings
var body: some View { var body: some View {
GlobalPlayerContainerView { GlobalPlayerContainerView {
@ -30,33 +28,31 @@ struct PhoneTabView: View {
} }
NavigationStack { NavigationStack {
List(moreTabs, id: \.self) { tab in if let selectedTab = selectedMoreTab {
tabView(for: selectedTab)
NavigationLink { .navigationTitle(selectedTab.label)
tabView(for: tab) } else {
.navigationTitle(tab.label) VStack(alignment: .leading) {
.onDisappear { List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
// tags and search handle navigation by own NavigationLink {
if tab != .tags && tab != .search { tabView(for: tab)
selectedMoreTab = nil .navigationTitle(tab.label)
} } label: {
Label(tab.label, systemImage: tab.systemImage)
} }
} label: { .listRowBackground(Color(R.color.bookmark_list_bg))
Label(tab.label, systemImage: tab.systemImage) }
} .navigationTitle("Mehr")
.listRowBackground(Color(R.color.bookmark_list_bg)) .scrollContentBackground(.hidden)
} .background(Color(R.color.bookmark_list_bg))
.navigationTitle("More")
.scrollContentBackground(.hidden)
.background(Color(R.color.bookmark_list_bg))
if appSettings.enableTTS { PlayerQueueResumeButton()
PlayerQueueResumeButton() .padding(.bottom, 16)
.padding(.top, 16) }
} }
} }
.tabItem { .tabItem {
Label("More", systemImage: "ellipsis") Label("Mehr", systemImage: "ellipsis")
} }
.tag(mainTabs.count) .tag(mainTabs.count)
.onAppear { .onAppear {
@ -91,7 +87,7 @@ struct PhoneTabView: View {
case .pictures: case .pictures:
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil)) BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
case .tags: case .tags:
LabelsView(selectedTag: .constant(nil)) LabelsView()
} }
} }
} }

View File

@ -13,10 +13,10 @@ struct PlayerQueueResumeButton: View {
}) { }) {
HStack(spacing: 12) { HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Read-aloud Queue") Text("Vorlese-Queue")
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("\(queue.queueItems.count) articles in the queue") Text("\(queue.queueItems.count) Artikel in der Queue")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.primary) .foregroundColor(.primary)
} }
@ -25,7 +25,7 @@ struct PlayerQueueResumeButton: View {
playerViewModel.resume() playerViewModel.resume()
playerUIState.showPlayer() playerUIState.showPlayer()
}) { }) {
Text("Resume listening") Text("Weiterhören")
.font(.subheadline) .font(.subheadline)
.fontWeight(.semibold) .fontWeight(.semibold)
.padding(.horizontal, 14) .padding(.horizontal, 14)

View File

@ -13,14 +13,14 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
var label: String { var label: String {
switch self { switch self {
case .all: return "All" case .all: return "All"
case .unread: return "Unread" case .unread: return "Ungelesen"
case .favorite: return "Favorites" case .favorite: return "Favoriten"
case .archived: return "Archive" case .archived: return "Archiv"
case .search: return "Search" case .search: return "Suche"
case .settings: return "Settings" case .settings: return "Einstellungen"
case .article: return "Articles" case .article: return "Artikel"
case .videos: return "Videos" case .videos: return "Videos"
case .pictures: return "Pictures" case .pictures: return "Bilder"
case .tags: return "Tags" case .tags: return "Tags"
} }
} }

View File

@ -1,33 +0,0 @@
//
// AppSettings.swift
// readeck
//
// Created by Ilyas Hallak on 21.07.25.
//
//
// AppSettings.swift
// readeck
//
// SPDX-License-Identifier: MIT
//
import Foundation
import Combine
class AppSettings: ObservableObject {
@Published var settings: Settings?
var enableTTS: Bool {
settings?.enableTTS ?? false
}
var theme: Theme {
settings?.theme ?? .system
}
init(settings: Settings? = nil) {
self.settings = settings
}
}

View File

@ -45,16 +45,8 @@ struct SearchBookmarksView: View {
if let bookmarks = viewModel.bookmarks?.bookmarks, !bookmarks.isEmpty { if let bookmarks = viewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
List(bookmarks) { bookmark in List(bookmarks) { bookmark in
NavigationLink { Button(action: {
BookmarkDetailView(bookmarkId: bookmark.id)
} label: {
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in })
.listRowBackground(Color(.systemBackground))
.padding(.vertical, 4)
}
/*Button(action: {
if UIDevice.isPhone { if UIDevice.isPhone {
selectedBookmarkId = bookmark.id selectedBookmarkId = bookmark.id
} else { } else {
@ -74,7 +66,6 @@ struct SearchBookmarksView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
*/
} }
.listStyle(.plain) .listStyle(.plain)
} else if !viewModel.isLoading && viewModel.bookmarks != nil { } else if !viewModel.isLoading && viewModel.bookmarks != nil {

View File

@ -8,11 +8,7 @@
import SwiftUI import SwiftUI
struct FontSettingsView: View { struct FontSettingsView: View {
@State private var viewModel: FontSettingsViewModel @State private var viewModel = FontSettingsViewModel()
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
self.viewModel = viewModel
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
@ -22,16 +18,16 @@ struct FontSettingsView: View {
.font(.title2) .font(.title2)
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
Text("Font") Text("Schrift")
.font(.title2) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
} }
// Font Family Picker // Font Family Picker
HStack(alignment: .firstTextBaseline, spacing: 16) { HStack(alignment: .firstTextBaseline, spacing: 16) {
Text("Font family") Text("Schriftart")
.font(.headline) .font(.headline)
Picker("Font family", selection: $viewModel.selectedFontFamily) { Picker("Schriftart", selection: $viewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family) Text(family.displayName).tag(family)
} }
@ -48,9 +44,9 @@ struct FontSettingsView: View {
// Font Size Picker // Font Size Picker
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Font size") Text("Schriftgröße")
.font(.headline) .font(.headline)
Picker("Font size", selection: $viewModel.selectedFontSize) { Picker("Schriftgröße", selection: $viewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in ForEach(FontSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size) Text(size.displayName).tag(size)
} }
@ -65,7 +61,7 @@ struct FontSettingsView: View {
// Font Preview // Font Preview
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Preview") Text("Vorschau")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -100,7 +96,5 @@ struct FontSettingsView: View {
} }
#Preview { #Preview {
FontSettingsView(viewModel: .init( FontSettingsView()
factory: MockUseCaseFactory())
)
} }

View File

@ -11,8 +11,8 @@ import SwiftUI
@Observable @Observable
class FontSettingsViewModel { class FontSettingsViewModel {
private let saveSettingsUseCase: PSaveSettingsUseCase private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase private let loadSettingsUseCase: LoadSettingsUseCase
// MARK: - Font Settings // MARK: - Font Settings
var selectedFontFamily: FontFamily = .system var selectedFontFamily: FontFamily = .system
@ -63,7 +63,8 @@ class FontSettingsViewModel {
} }
} }
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) { init() {
let factory = DefaultUseCaseFactory.shared
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase() self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
} }
@ -76,7 +77,7 @@ class FontSettingsViewModel {
selectedFontSize = settings.fontSize ?? .medium selectedFontSize = settings.fontSize ?? .medium
} }
} catch { } catch {
errorMessage = "Error loading font settings" errorMessage = "Fehler beim Laden der Schrift-Einstellungen"
} }
} }
@ -87,9 +88,9 @@ class FontSettingsViewModel {
selectedFontFamily: selectedFontFamily, selectedFontFamily: selectedFontFamily,
selectedFontSize: selectedFontSize selectedFontSize: selectedFontSize
) )
successMessage = "Font settings saved" successMessage = "Schrift-Einstellungen gespeichert"
} catch { } catch {
errorMessage = "Error saving font settings" errorMessage = "Fehler beim Speichern der Schrift-Einstellungen"
} }
} }

View File

@ -15,7 +15,3 @@ struct SectionHeader: View {
} }
} }
} }
#Preview {
SectionHeader(title: "hello", icon: "person.circle")
}

View File

@ -8,68 +8,24 @@
import SwiftUI import SwiftUI
struct SettingsContainerView: View { struct SettingsContainerView: View {
private var appVersion: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
return "v\(version) (\(build))"
}
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack(spacing: 20) { LazyVStack(spacing: 20) {
SettingsServerView()
.cardStyle()
FontSettingsView() FontSettingsView()
.cardStyle() .cardStyle()
SettingsGeneralView() SettingsGeneralView()
.cardStyle() .cardStyle()
SettingsServerView()
.cardStyle()
} }
.padding() .padding()
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
AppInfo()
Spacer()
} }
.background(Color(.systemGroupedBackground)) .navigationTitle("Einstellungen")
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} }
@ViewBuilder
func AppInfo() -> some View {
VStack(spacing: 4) {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
Text("Version \(appVersion)")
.font(.footnote)
.foregroundColor(.secondary)
}
HStack(spacing: 8) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
Text("Developer: Ilyas Hallak")
.font(.footnote)
.foregroundColor(.secondary)
}
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundColor(.secondary)
Text("From Bremen with 💚")
.font(.footnote)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
.padding(.bottom, 4)
.multilineTextAlignment(.center)
.opacity(0.7)
}
} }
// Card Modifier für einheitlichen Look // Card Modifier für einheitlichen Look

View File

@ -6,17 +6,14 @@
// //
import SwiftUI import SwiftUI
// SectionHeader wird jetzt zentral importiert
struct SettingsGeneralView: View { struct SettingsGeneralView: View {
@State private var viewModel: SettingsGeneralViewModel @State private var viewModel = SettingsGeneralViewModel()
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
self.viewModel = viewModel
}
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
SectionHeader(title: "General Settings", icon: "gear") SectionHeader(title: "Allgemeine Einstellungen", icon: "gear")
.padding(.bottom, 4) .padding(.bottom, 4)
// Theme // Theme
@ -29,58 +26,38 @@ struct SettingsGeneralView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: viewModel.selectedTheme) {
Task {
await viewModel.saveGeneralSettings()
}
}
} }
VStack(alignment: .leading, spacing: 12) {
Text("General")
.font(.headline)
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
.toggleStyle(.switch)
.onChange(of: viewModel.enableTTS) {
Task {
await viewModel.saveGeneralSettings()
}
}
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
.font(.footnote)
}
#if DEBUG
// Sync Settings // Sync Settings
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Sync Settings") Text("Sync-Einstellungen")
.font(.headline) .font(.headline)
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled) Toggle("Automatischer Sync", isOn: $viewModel.autoSyncEnabled)
.toggleStyle(SwitchToggleStyle()) .toggleStyle(SwitchToggleStyle())
if viewModel.autoSyncEnabled { if viewModel.autoSyncEnabled {
HStack { HStack {
Text("Sync interval") Text("Sync-Intervall")
Spacer() Spacer()
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60) Stepper("\(viewModel.syncInterval) Minuten", value: $viewModel.syncInterval, in: 1...60)
} }
} }
} }
// Reading Settings // Reading Settings
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Reading Settings") Text("Leseeinstellungen")
.font(.headline) .font(.headline)
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) Toggle("Safari Reader Modus", isOn: $viewModel.enableReaderMode)
.toggleStyle(SwitchToggleStyle()) .toggleStyle(SwitchToggleStyle())
Toggle("Open external links in in-app Safari", isOn: $viewModel.openExternalLinksInApp) Toggle("Externe Links in In-App Safari öffnen", isOn: $viewModel.openExternalLinksInApp)
.toggleStyle(SwitchToggleStyle()) .toggleStyle(SwitchToggleStyle())
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead) Toggle("Artikel automatisch als gelesen markieren", isOn: $viewModel.autoMarkAsRead)
.toggleStyle(SwitchToggleStyle()) .toggleStyle(SwitchToggleStyle())
} }
// Data Management // Data Management
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Data Management") Text("Datenmanagement")
.font(.headline) .font(.headline)
Button(role: .destructive) { Button(role: .destructive) {
Task { Task {
@ -90,7 +67,7 @@ struct SettingsGeneralView: View {
HStack { HStack {
Image(systemName: "trash") Image(systemName: "trash")
.foregroundColor(.red) .foregroundColor(.red)
Text("Clear cache") Text("Cache leeren")
.foregroundColor(.red) .foregroundColor(.red)
Spacer() Spacer()
} }
@ -103,13 +80,53 @@ struct SettingsGeneralView: View {
HStack { HStack {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
.foregroundColor(.red) .foregroundColor(.red)
Text("Reset settings") Text("Einstellungen zurücksetzen")
.foregroundColor(.red) .foregroundColor(.red)
Spacer() Spacer()
} }
} }
} }
// App Info
VStack(alignment: .leading, spacing: 12) {
Text("Über die App")
.font(.headline)
HStack {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
Text("Version \(viewModel.appVersion)")
Spacer()
}
HStack {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
Text("Entwickler: \(viewModel.developerName)")
Spacer()
}
HStack {
Image(systemName: "globe")
.foregroundColor(.secondary)
Link("Website", destination: URL(string: "https://example.com")!)
Spacer()
}
}
// Save Button
Button(action: {
Task {
await viewModel.saveGeneralSettings()
}
}) {
HStack {
Text("Einstellungen speichern")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
// Messages // Messages
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
HStack { HStack {
@ -129,8 +146,6 @@ struct SettingsGeneralView: View {
.font(.caption) .font(.caption)
} }
} }
#endif
} }
.task { .task {
await viewModel.loadGeneralSettings() await viewModel.loadGeneralSettings()
@ -138,8 +153,21 @@ struct SettingsGeneralView: View {
} }
} }
#Preview { enum Theme: String, CaseIterable {
SettingsGeneralView(viewModel: .init( case system = "system"
MockUseCaseFactory() case light = "light"
)) case dark = "dark"
var displayName: String {
switch self {
case .system: return "System"
case .light: return "Hell"
case .dark: return "Dunkel"
}
}
}
#Preview {
SettingsGeneralView()
} }

View File

@ -4,8 +4,8 @@ import SwiftUI
@Observable @Observable
class SettingsGeneralViewModel { class SettingsGeneralViewModel {
private let saveSettingsUseCase: PSaveSettingsUseCase private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase private let loadSettingsUseCase: LoadSettingsUseCase
// MARK: - UI Settings // MARK: - UI Settings
var selectedTheme: Theme = .system var selectedTheme: Theme = .system
@ -14,18 +14,20 @@ class SettingsGeneralViewModel {
var syncInterval: Int = 15 var syncInterval: Int = 15
// MARK: - Reading Settings // MARK: - Reading Settings
var enableReaderMode: Bool = false var enableReaderMode: Bool = false
var enableTTS: Bool = false
var openExternalLinksInApp: Bool = true var openExternalLinksInApp: Bool = true
var autoMarkAsRead: Bool = false var autoMarkAsRead: Bool = false
// MARK: - App Info
var appVersion: String = "1.0.0"
var developerName: String = "Your Name"
// MARK: - Messages // MARK: - Messages
var errorMessage: String? var errorMessage: String?
var successMessage: String? var successMessage: String?
// MARK: - Data Management (Platzhalter)
// func clearCache() async {}
// func resetSettings() async {}
// MARK: - Data Management (Placeholder) init() {
let factory = DefaultUseCaseFactory.shared
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase() self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
} }
@ -34,27 +36,37 @@ class SettingsGeneralViewModel {
func loadGeneralSettings() async { func loadGeneralSettings() async {
do { do {
if let settings = try await loadSettingsUseCase.execute() { if let settings = try await loadSettingsUseCase.execute() {
enableTTS = settings.enableTTS ?? false selectedTheme = .system // settings.theme ?? .system
selectedTheme = settings.theme ?? .system autoSyncEnabled = false // settings.autoSyncEnabled
autoSyncEnabled = false // syncInterval = settings.syncInterval
// enableReaderMode = settings.enableReaderMode
// openExternalLinksInApp = settings.openExternalLinksInApp
// autoMarkAsRead = settings.autoMarkAsRead
appVersion = "1.0.0"
developerName = "Ilyas Hallak"
} }
} catch { } catch {
errorMessage = "Error loading settings" errorMessage = "Fehler beim Laden der Einstellungen"
} }
} }
@MainActor @MainActor
func saveGeneralSettings() async { func saveGeneralSettings() async {
do { do {
try await saveSettingsUseCase.execute(enableTTS: enableTTS)
try await saveSettingsUseCase.execute(theme: selectedTheme)
successMessage = "Settings saved" // TODO: add save general settings here
/*try await saveSettingsUseCase.execute(
// send notification to apply settings to the app token: "",
NotificationCenter.default.post(name: NSNotification.Name("SettingsChanged"), object: nil) selectedTheme: selectedTheme,
autoSyncEnabled: autoSyncEnabled,
syncInterval: syncInterval,
enableReaderMode: enableReaderMode,
openExternalLinksInApp: openExternalLinksInApp,
autoMarkAsRead: autoMarkAsRead
)*/
successMessage = "Einstellungen gespeichert"
} catch { } catch {
errorMessage = "Error saving settings" errorMessage = "Fehler beim Speichern der Einstellungen"
} }
} }

View File

@ -11,19 +11,14 @@ struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel() @State private var viewModel = SettingsServerViewModel()
@State private var showingLogoutAlert = false @State private var showingLogoutAlert = false
init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) {
self.viewModel = viewModel
self.showingLogoutAlert = showingLogoutAlert
}
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
SectionHeader(title: viewModel.isSetupMode ? "Server Settings" : "Server Connection", icon: "server.rack") SectionHeader(title: viewModel.isSetupMode ? "Server-Einstellungen" : "Server-Verbindung", icon: "server.rack")
.padding(.bottom, 4) .padding(.bottom, 4)
Text(viewModel.isSetupMode ? Text(viewModel.isSetupMode ?
"Enter your Readeck server details to get started." : "Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." :
"Your current server connection and login credentials.") "Ihre aktuelle Server-Verbindung und Anmeldedaten.")
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -32,63 +27,45 @@ struct SettingsServerView: View {
// Form // Form
VStack(spacing: 16) { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Server Endpoint") Text("Server-Endpunkt")
.font(.headline) .font(.headline)
if viewModel.isSetupMode { TextField("https://readeck.example.com", text: $viewModel.endpoint)
TextField("https://readeck.example.com", text: $viewModel.endpoint) .textFieldStyle(.roundedBorder)
.textFieldStyle(.roundedBorder) .keyboardType(.URL)
.keyboardType(.URL) .autocapitalization(.none)
.autocapitalization(.none) .disableAutocorrection(true)
.disableAutocorrection(true) .disabled(!viewModel.isSetupMode)
.onChange(of: viewModel.endpoint) { .onChange(of: viewModel.endpoint) {
if viewModel.isSetupMode {
viewModel.clearMessages() viewModel.clearMessages()
} }
} else {
HStack {
Image(systemName: "server.rack")
.foregroundColor(.accentColor)
Text(viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint)
.foregroundColor(viewModel.endpoint.isEmpty ? .secondary : .primary)
Spacer()
} }
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Username") Text("Benutzername")
.font(.headline) .font(.headline)
if viewModel.isSetupMode { TextField("Ihr Benutzername", text: $viewModel.username)
TextField("Your Username", text: $viewModel.username) .textFieldStyle(.roundedBorder)
.textFieldStyle(.roundedBorder) .autocapitalization(.none)
.autocapitalization(.none) .disableAutocorrection(true)
.disableAutocorrection(true) .disabled(!viewModel.isSetupMode)
.onChange(of: viewModel.username) { .onChange(of: viewModel.username) {
if viewModel.isSetupMode {
viewModel.clearMessages() viewModel.clearMessages()
} }
} else {
HStack {
Image(systemName: "person.circle.fill")
.foregroundColor(.accentColor)
Text(viewModel.username.isEmpty ? "Not set" : viewModel.username)
.foregroundColor(viewModel.username.isEmpty ? .secondary : .primary)
Spacer()
} }
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
} }
if viewModel.isSetupMode { VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 6) { Text("Passwort")
Text("Password") .font(.headline)
.font(.headline) SecureField("Ihr Passwort", text: $viewModel.password)
.textFieldStyle(.roundedBorder)
SecureField("Your Password", text: $viewModel.password) .disabled(!viewModel.isSetupMode)
.textFieldStyle(.roundedBorder) .onChange(of: viewModel.password) {
.onChange(of: viewModel.password) { if viewModel.isSetupMode {
viewModel.clearMessages() viewModel.clearMessages()
} }
} }
} }
} }
@ -97,7 +74,7 @@ struct SettingsServerView: View {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
Text("Successfully logged in") Text("Erfolgreich angemeldet")
.foregroundColor(.green) .foregroundColor(.green)
.font(.caption) .font(.caption)
} }
@ -113,7 +90,6 @@ struct SettingsServerView: View {
.font(.caption) .font(.caption)
} }
} }
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
@ -137,7 +113,7 @@ struct SettingsServerView: View {
.scaleEffect(0.8) .scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .progressViewStyle(CircularProgressViewStyle(tint: .white))
} }
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save")) Text(viewModel.isLoading ? "Speichern..." : (viewModel.isLoggedIn ? "Erneut anmelden & speichern" : "Anmelden & speichern"))
.fontWeight(.semibold) .fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -147,6 +123,13 @@ struct SettingsServerView: View {
.cornerRadius(10) .cornerRadius(10)
} }
.disabled(!viewModel.canLogin || viewModel.isLoading) .disabled(!viewModel.canLogin || viewModel.isLoading)
Button("Debug-Anmeldung") {
viewModel.username = "admin"
viewModel.password = "Diggah123"
viewModel.endpoint = "https://readeck.mnk.any64.de"
}
.font(.caption)
.foregroundColor(.secondary)
} }
} else { } else {
Button(action: { Button(action: {
@ -154,7 +137,7 @@ struct SettingsServerView: View {
}) { }) {
HStack { HStack {
Image(systemName: "rectangle.portrait.and.arrow.right") Image(systemName: "rectangle.portrait.and.arrow.right")
Text("Logout") Text("Abmelden")
.fontWeight(.semibold) .fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -165,15 +148,15 @@ struct SettingsServerView: View {
} }
} }
} }
.alert("Logout", isPresented: $showingLogoutAlert) { .alert("Abmelden", isPresented: $showingLogoutAlert) {
Button("Cancel", role: .cancel) { } Button("Abbrechen", role: .cancel) { }
Button("Logout", role: .destructive) { Button("Abmelden", role: .destructive) {
Task { Task {
await viewModel.logout() await viewModel.logout()
} }
} }
} message: { } message: {
Text("Are you sure you want to log out? This will delete all your login credentials and return you to setup.") Text("Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen.")
} }
.task { .task {
await viewModel.loadServerSettings() await viewModel.loadServerSettings()
@ -182,7 +165,5 @@ struct SettingsServerView: View {
} }
#Preview { #Preview {
SettingsServerView(viewModel: .init( SettingsServerView()
MockUseCaseFactory()
))
} }

Some files were not shown because too many files have changed in this diff Show More