Compare commits
21 Commits
e68959afce
...
a09cad5d7e
| Author | SHA1 | Date | |
|---|---|---|---|
| a09cad5d7e | |||
| 61b30112ee | |||
| 3aecdf9ba2 | |||
| 5b2d177f94 | |||
| d036c2e658 | |||
| 03713230b0 | |||
| 1cb87a4fb7 | |||
| edf1234b53 | |||
| 176885442e | |||
| dd1b2628b6 | |||
| 8e8e67bfe1 | |||
| 15ce5a223b | |||
| bdd7d234a9 | |||
| 89c1c3c892 | |||
| 8d4b08da11 | |||
| 387a026e7d | |||
| c52d974b05 | |||
| 07384215eb | |||
| 930779169b | |||
| 7861368196 | |||
| 530a916552 |
3
.gitignore
vendored
@ -61,4 +61,5 @@ fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
fastlane/.env.default
|
||||
fastlane/AuthKey_JZJCQWW9N3.p8
|
||||
|
||||
1
.ruby-version
Normal file
@ -0,0 +1 @@
|
||||
3.3.0
|
||||
25
CHANGELOG.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 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
|
||||
58
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,58 @@
|
||||
# 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
|
||||
59
Contribute.md
Normal file
@ -0,0 +1,59 @@
|
||||
# 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! Here’s 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!
|
||||
227
Gemfile.lock
Normal file
@ -0,0 +1,227 @@
|
||||
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
|
||||
@ -3,6 +3,12 @@
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
"(%lld found)" : {
|
||||
|
||||
},
|
||||
"%" : {
|
||||
|
||||
},
|
||||
"%@ (%lld)" : {
|
||||
"localizations" : {
|
||||
@ -17,13 +23,13 @@
|
||||
"%lld" : {
|
||||
|
||||
},
|
||||
"%lld Artikel in der Queue" : {
|
||||
"%lld articles in the queue" : {
|
||||
|
||||
},
|
||||
"%lld min" : {
|
||||
|
||||
},
|
||||
"%lld Minuten" : {
|
||||
"%lld minutes" : {
|
||||
|
||||
},
|
||||
"%lld." : {
|
||||
@ -42,13 +48,13 @@
|
||||
"12 min • Today • example.com" : {
|
||||
|
||||
},
|
||||
"Abbrechen" : {
|
||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." : {
|
||||
|
||||
},
|
||||
"Abmelden" : {
|
||||
"Add" : {
|
||||
|
||||
},
|
||||
"Aktuelle Labels" : {
|
||||
"Add new tag:" : {
|
||||
|
||||
},
|
||||
"all" : {
|
||||
@ -62,100 +68,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Anmelden & speichern" : {
|
||||
"All tags selected" : {
|
||||
|
||||
},
|
||||
"Archivieren" : {
|
||||
"Archive" : {
|
||||
|
||||
},
|
||||
"Artikel automatisch als gelesen markieren" : {
|
||||
"Archive bookmark" : {
|
||||
|
||||
},
|
||||
"Artikel vorlesen" : {
|
||||
"Are you sure you want to delete this bookmark? This action cannot be undone." : {
|
||||
|
||||
},
|
||||
"Automatischer Sync" : {
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
||||
|
||||
},
|
||||
"Benutzername" : {
|
||||
"Automatic sync" : {
|
||||
|
||||
},
|
||||
"Bookmark archivieren" : {
|
||||
"Automatically mark articles as read" : {
|
||||
|
||||
},
|
||||
"Bookmark ist archiviert" : {
|
||||
"Available tags" : {
|
||||
|
||||
},
|
||||
"Bookmark speichern" : {
|
||||
"Cancel" : {
|
||||
|
||||
},
|
||||
"Cache leeren" : {
|
||||
"Clear cache" : {
|
||||
|
||||
},
|
||||
"Datenmanagement" : {
|
||||
"Close" : {
|
||||
|
||||
},
|
||||
"Debug-Anmeldung" : {
|
||||
"Data Management" : {
|
||||
|
||||
},
|
||||
"Einfügen" : {
|
||||
"Delete" : {
|
||||
|
||||
},
|
||||
"Einstellungen" : {
|
||||
"Delete Bookmark" : {
|
||||
|
||||
},
|
||||
"Einstellungen speichern" : {
|
||||
"Developer: Ilyas Hallak" : {
|
||||
|
||||
},
|
||||
"Einstellungen zurücksetzen" : {
|
||||
"Done" : {
|
||||
|
||||
},
|
||||
"Entfernen" : {
|
||||
"Enter an optional title..." : {
|
||||
|
||||
},
|
||||
"Entwickler: %@" : {
|
||||
"Enter your Readeck server details to get started." : {
|
||||
|
||||
},
|
||||
"Erfolgreich angemeldet" : {
|
||||
"Error" : {
|
||||
|
||||
},
|
||||
"Erforderlich" : {
|
||||
"Error: %@" : {
|
||||
|
||||
},
|
||||
"Erneut anmelden & speichern" : {
|
||||
"Favorite" : {
|
||||
|
||||
},
|
||||
"Es wurden noch keine Bookmarks in %@ gefunden." : {
|
||||
"Finished reading?" : {
|
||||
|
||||
},
|
||||
"Externe Links in In-App Safari öffnen" : {
|
||||
"Font" : {
|
||||
|
||||
},
|
||||
"Favorit" : {
|
||||
"Font family" : {
|
||||
|
||||
},
|
||||
"Fehler" : {
|
||||
"Font Settings" : {
|
||||
|
||||
},
|
||||
"Fehler: %@" : {
|
||||
"Font size" : {
|
||||
|
||||
},
|
||||
"Fertig" : {
|
||||
"From Bremen with 💚" : {
|
||||
|
||||
},
|
||||
"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" : {
|
||||
"General" : {
|
||||
|
||||
},
|
||||
"https://example.com" : {
|
||||
@ -164,19 +158,7 @@
|
||||
"https://readeck.example.com" : {
|
||||
|
||||
},
|
||||
"Ihr Benutzername" : {
|
||||
|
||||
},
|
||||
"Ihr Passwort" : {
|
||||
|
||||
},
|
||||
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
|
||||
|
||||
},
|
||||
"Keine Artikel in der Queue" : {
|
||||
|
||||
},
|
||||
"Keine Bookmarks" : {
|
||||
"Jump to last read position (%lld%%)" : {
|
||||
|
||||
},
|
||||
"Keine Bookmarks gefunden." : {
|
||||
@ -184,93 +166,141 @@
|
||||
},
|
||||
"Keine Ergebnisse" : {
|
||||
|
||||
},
|
||||
"Keine Labels vorhanden" : {
|
||||
|
||||
},
|
||||
"Key" : {
|
||||
"extractionState" : "manual"
|
||||
},
|
||||
"Label eingeben..." : {
|
||||
"Loading %@" : {
|
||||
|
||||
},
|
||||
"Labels" : {
|
||||
"Loading article..." : {
|
||||
|
||||
},
|
||||
"Labels verwalten" : {
|
||||
"Login & Save" : {
|
||||
|
||||
},
|
||||
"Lade %@..." : {
|
||||
"Logout" : {
|
||||
|
||||
},
|
||||
"Lade Artikel..." : {
|
||||
"Manage Labels" : {
|
||||
|
||||
},
|
||||
"Lese %lld/%lld: " : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Lese %1$lld/%2$lld: "
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Leseeinstellungen" : {
|
||||
"Mark as favorite" : {
|
||||
|
||||
},
|
||||
"Löschen" : {
|
||||
"More" : {
|
||||
|
||||
},
|
||||
"Mehr" : {
|
||||
"New Bookmark" : {
|
||||
|
||||
},
|
||||
"Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen." : {
|
||||
"No articles in the queue" : {
|
||||
|
||||
},
|
||||
"Neues Bookmark" : {
|
||||
"No bookmarks" : {
|
||||
|
||||
},
|
||||
"Neues Label hinzufügen" : {
|
||||
"No bookmarks found in %@." : {
|
||||
|
||||
},
|
||||
"OK" : {
|
||||
|
||||
},
|
||||
"Optional: Eigener Titel" : {
|
||||
"Open external links in in-app Safari" : {
|
||||
|
||||
},
|
||||
"Passwort" : {
|
||||
"Optional: Custom title" : {
|
||||
|
||||
},
|
||||
"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" : {
|
||||
|
||||
},
|
||||
"Safari Reader Modus" : {
|
||||
"Reading %lld/%lld: " : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Reading %1$lld/%2$lld: "
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reading Settings" : {
|
||||
|
||||
},
|
||||
"Schließen" : {
|
||||
"Remove" : {
|
||||
|
||||
},
|
||||
"Schrift" : {
|
||||
"Reset settings" : {
|
||||
|
||||
},
|
||||
"Schrift-Einstellungen" : {
|
||||
"Restore" : {
|
||||
|
||||
},
|
||||
"Schriftart" : {
|
||||
"Resume listening" : {
|
||||
|
||||
},
|
||||
"Schriftgröße" : {
|
||||
"Safari Reader Mode" : {
|
||||
|
||||
},
|
||||
"Save bookmark" : {
|
||||
|
||||
},
|
||||
"Save Bookmark" : {
|
||||
|
||||
},
|
||||
"Saving..." : {
|
||||
|
||||
},
|
||||
"Search or add new tag..." : {
|
||||
|
||||
},
|
||||
"Search results" : {
|
||||
|
||||
},
|
||||
"Select a bookmark or tag" : {
|
||||
|
||||
},
|
||||
"Server-Endpunkt" : {
|
||||
"Selected tags" : {
|
||||
|
||||
},
|
||||
"Speichern..." : {
|
||||
"Server Endpoint" : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
|
||||
},
|
||||
"Speed" : {
|
||||
|
||||
},
|
||||
"Successfully logged in" : {
|
||||
|
||||
},
|
||||
"Suchbegriff eingeben..." : {
|
||||
@ -282,13 +312,10 @@
|
||||
"Suche..." : {
|
||||
|
||||
},
|
||||
"Sync-Einstellungen" : {
|
||||
"Sync interval" : {
|
||||
|
||||
},
|
||||
"Sync-Intervall" : {
|
||||
|
||||
},
|
||||
"Tags" : {
|
||||
"Sync Settings" : {
|
||||
|
||||
},
|
||||
"Theme" : {
|
||||
@ -297,43 +324,25 @@
|
||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." : {
|
||||
|
||||
},
|
||||
"Titel" : {
|
||||
"Unarchive Bookmark" : {
|
||||
|
||||
},
|
||||
"Über die App" : {
|
||||
"URL in clipboard:" : {
|
||||
|
||||
},
|
||||
"URL" : {
|
||||
|
||||
},
|
||||
"URL gefunden:" : {
|
||||
"Username" : {
|
||||
|
||||
},
|
||||
"Version %@" : {
|
||||
|
||||
},
|
||||
"Vorlese-Queue" : {
|
||||
"Your current server connection and login credentials." : {
|
||||
|
||||
},
|
||||
"Vorschau" : {
|
||||
"Your Password" : {
|
||||
|
||||
},
|
||||
"Website" : {
|
||||
|
||||
},
|
||||
"Weiterhören" : {
|
||||
|
||||
},
|
||||
"Wiederherstellen" : {
|
||||
|
||||
},
|
||||
"Wird gespeichert..." : {
|
||||
|
||||
},
|
||||
"z.B. arbeit, wichtig, später" : {
|
||||
|
||||
},
|
||||
"Zwischenablage" : {
|
||||
"Your Username" : {
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
32
README.md
@ -1,4 +1,4 @@
|
||||
# ReadKeep iOS App
|
||||
# Readeck iOS App
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
@ -8,7 +8,23 @@ A native iOS client for [readeck](https://readeck.org) bookmark management.
|
||||
The official repository is on Codeberg:
|
||||
https://codeberg.org/readeck/readeck
|
||||
|
||||
## Features
|
||||
## TestFlight Beta Access
|
||||
|
||||
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)
|
||||
- Share Extension for adding URLs from Safari and other apps
|
||||
@ -18,6 +34,7 @@ https://codeberg.org/readeck/readeck
|
||||
- Font Customization
|
||||
- Article View with Reading Time and Word Count
|
||||
- Search functionality
|
||||
- Support for reading progress
|
||||
|
||||
## Configuration
|
||||
|
||||
@ -27,6 +44,7 @@ After installing the app:
|
||||
2. Enter your readeck server URL and credentials
|
||||
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
|
||||
|
||||
@ -37,13 +55,9 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
|
||||
3. Enter a title if you want and hit save
|
||||
4. The bookmark is automatically added to your collection
|
||||
|
||||
## Planned Features
|
||||
- [ ] Add support for bookmark filtering and sorting options
|
||||
- [ ] Add support for tags
|
||||
- [ ] Offline sync with Core Data
|
||||
- [ ] Add support for collection management
|
||||
- [ ] Add offline sync capabilities
|
||||
- [ ] Add support for custom themes
|
||||
## Versions
|
||||
|
||||
[see Changelog](./CHANGELOG.md)
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
208
URLShare/ShareBookmarkView.swift
Normal file
@ -0,0 +1,208 @@
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
108
URLShare/ShareBookmarkViewModel.swift
Normal file
@ -0,0 +1,108 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,349 +8,42 @@
|
||||
import UIKit
|
||||
import Social
|
||||
import UniformTypeIdentifiers
|
||||
import SwiftUI
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
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?
|
||||
private var hostingController: UIHostingController<ShareBookmarkView>?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
extractSharedContent()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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 }!
|
||||
|
||||
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext)
|
||||
let swiftUIView = ShareBookmarkView(viewModel: viewModel)
|
||||
let hostingController = UIHostingController(rootView: swiftUIView)
|
||||
addChild(hostingController)
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(hostingController.view)
|
||||
NSLayoutConstraint.activate([
|
||||
// Custom Cancel Button
|
||||
customCancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
||||
customCancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
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.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||
])
|
||||
hostingController.didMove(toParent: self)
|
||||
self.hostingController = hostingController
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(dismissKeyboard),
|
||||
name: NSNotification.Name("DismissKeyboard"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Content Extraction
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@objc private func dismissKeyboard() {
|
||||
self.view.endEditing(true)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@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
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
84
URLShare/SimpleAPI.swift
Normal file
@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
URLShare/SimpleAPIDTOs.swift
Normal file
@ -0,0 +1,36 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
|
||||
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
133
documentation/Architecture.md
Normal file
@ -0,0 +1,133 @@
|
||||
// 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/)
|
||||
6
fastlane/Appfile
Normal file
@ -0,0 +1,6 @@
|
||||
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
|
||||
32
fastlane/Fastfile
Normal file
@ -0,0 +1,32 @@
|
||||
# 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
|
||||
|
||||
|
||||
32
fastlane/Snapfile
Normal file
@ -0,0 +1,32 @@
|
||||
# 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
|
||||
313
fastlane/SnapshotHelper.swift
Normal file
@ -0,0 +1,313 @@
|
||||
//
|
||||
// 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]
|
||||
@ -10,7 +10,6 @@
|
||||
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
||||
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FC2E17C3B3007531C3 /* rswift */; };
|
||||
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
||||
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
||||
/* End PBXBuildFile section */
|
||||
@ -86,7 +85,13 @@
|
||||
Data/CoreData/CoreDataManager.swift,
|
||||
Data/KeychainHelper.swift,
|
||||
Domain/Model/Bookmark.swift,
|
||||
Domain/Model/BookmarkLabel.swift,
|
||||
readeck.xcdatamodeld,
|
||||
Splash.storyboard,
|
||||
UI/Components/Constants.swift,
|
||||
UI/Components/CustomTextFieldStyle.swift,
|
||||
UI/Components/TagManagementView.swift,
|
||||
UI/Components/UnifiedLabelChip.swift,
|
||||
);
|
||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||
};
|
||||
@ -143,7 +148,6 @@
|
||||
files = (
|
||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -235,7 +239,6 @@
|
||||
packageProductDependencies = (
|
||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||
5DA241FC2E17C3B3007531C3 /* rswift */,
|
||||
);
|
||||
productName = readeck;
|
||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||
@ -446,10 +449,14 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2.URLShare;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
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_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@ -475,10 +482,14 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2.URLShare;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
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_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@ -542,6 +553,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@ -597,6 +609,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
};
|
||||
@ -610,13 +623,15 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@ -625,21 +640,21 @@
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 2.1;
|
||||
};
|
||||
name = Debug;
|
||||
@ -652,13 +667,15 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@ -667,21 +684,21 @@
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 2.1;
|
||||
};
|
||||
name = Release;
|
||||
@ -856,11 +873,6 @@
|
||||
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
||||
productName = RswiftLibrary;
|
||||
};
|
||||
5DA241FC2E17C3B3007531C3 /* rswift */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
||||
productName = rswift;
|
||||
};
|
||||
5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
||||
|
||||
96
readeck.xcodeproj/xcshareddata/xcschemes/URLShare.xcscheme
Normal file
@ -0,0 +1,96 @@
|
||||
<?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>
|
||||
102
readeck.xcodeproj/xcshareddata/xcschemes/readeck.xcscheme
Normal file
@ -0,0 +1,102 @@
|
||||
<?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>
|
||||
@ -23,9 +23,9 @@
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x5A",
|
||||
"green" : "0x4A",
|
||||
"red" : "0x1F"
|
||||
"blue" : "0x4B",
|
||||
"green" : "0x41",
|
||||
"red" : "0x20"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
||||
59
readeck/Assets.xcassets/logo.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
BIN
readeck/Assets.xcassets/logo.imageset/logo 1.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo 2.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo 3.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo 4.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo 5.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
readeck/Assets.xcassets/logo.imageset/logo.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
26
readeck/Assets.xcassets/splash.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
BIN
readeck/Assets.xcassets/splash.imageset/readeck 1.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
readeck/Assets.xcassets/splash.imageset/readeck 2.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
readeck/Assets.xcassets/splash.imageset/readeck.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x5A",
|
||||
"green" : "0x4A",
|
||||
"red" : "0x1F"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
38
readeck/Assets.xcassets/splashBg.colorset/Contents.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ class KeychainHelper {
|
||||
static let shared = KeychainHelper()
|
||||
private init() {}
|
||||
|
||||
private static let accessGroup = "8J69P655GN.de.ilyashallak.readeck2"
|
||||
private static let accessGroup = "8J69P655GN.de.ilyashallak.readeck"
|
||||
|
||||
@discardableResult
|
||||
func saveToken(_ token: String) -> Bool {
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -31,7 +31,8 @@ class BookmarksRepository: PBookmarksRepository {
|
||||
labels: bookmarkDetailDto.labels,
|
||||
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
|
||||
imageUrl: bookmarkDetailDto.resources.image?.src ?? "",
|
||||
lang: bookmarkDetailDto.lang ?? ""
|
||||
lang: bookmarkDetailDto.lang ?? "",
|
||||
readProgress: bookmarkDetailDto.readProgress
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ struct Settings {
|
||||
var fontFamily: FontFamily? = nil
|
||||
var fontSize: FontSize? = nil
|
||||
var hasFinishedSetup: Bool = false
|
||||
var enableTTS: Bool? = nil
|
||||
var theme: Theme? = nil
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
token != nil && !token!.isEmpty
|
||||
@ -69,6 +71,13 @@ class SettingsRepository: PSettingsRepository {
|
||||
if let fontSize = settings.fontSize {
|
||||
existingSettings.fontSize = fontSize.rawValue
|
||||
}
|
||||
if let enableTTS = settings.enableTTS {
|
||||
existingSettings.enableTTS = enableTTS
|
||||
}
|
||||
|
||||
if let theme = settings.theme {
|
||||
existingSettings.theme = theme.rawValue
|
||||
}
|
||||
|
||||
try context.save()
|
||||
}
|
||||
@ -99,7 +108,9 @@ class SettingsRepository: PSettingsRepository {
|
||||
password: settingEntity.password ?? "",
|
||||
token: settingEntity.token,
|
||||
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)
|
||||
} else {
|
||||
|
||||
@ -14,7 +14,7 @@ class CoreDataTokenProvider: TokenProvider {
|
||||
private let keychainHelper = KeychainHelper.shared
|
||||
|
||||
private func loadSettingsIfNeeded() async {
|
||||
guard !isLoaded else { return }
|
||||
guard isLoaded == false || cachedSettings == nil else { return }
|
||||
|
||||
do {
|
||||
cachedSettings = try await settingsRepository.loadSettings()
|
||||
|
||||
@ -12,13 +12,14 @@ struct BookmarkDetail {
|
||||
let wordCount: Int?
|
||||
let readingTime: Int?
|
||||
let hasArticle: Bool
|
||||
let isMarked: Bool
|
||||
var isMarked: Bool
|
||||
var isArchived: Bool
|
||||
let labels: [String]
|
||||
let thumbnailUrl: String
|
||||
let imageUrl: String
|
||||
let lang: String
|
||||
var content: String?
|
||||
let readProgress: Int?
|
||||
}
|
||||
|
||||
extension BookmarkDetail {
|
||||
@ -39,6 +40,7 @@ extension BookmarkDetail {
|
||||
labels: [],
|
||||
thumbnailUrl: "",
|
||||
imageUrl: "",
|
||||
lang: ""
|
||||
lang: "",
|
||||
readProgress: 0
|
||||
)
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ struct BookmarkUpdateRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience Initializers für häufige Aktionen
|
||||
|
||||
extension BookmarkUpdateRequest {
|
||||
static func archive(_ isArchived: Bool) -> BookmarkUpdateRequest {
|
||||
return BookmarkUpdateRequest(isArchived: isArchived)
|
||||
@ -67,4 +67,4 @@ extension BookmarkUpdateRequest {
|
||||
static func removeLabels(_ labels: [String]) -> BookmarkUpdateRequest {
|
||||
return BookmarkUpdateRequest(removeLabels: labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
readeck/Domain/Model/Theme.swift
Normal file
@ -0,0 +1,31 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
class AddLabelsToBookmarkUseCase {
|
||||
protocol PAddLabelsToBookmarkUseCase {
|
||||
func execute(bookmarkId: String, labels: [String]) async throws
|
||||
func execute(bookmarkId: String, label: String) async throws
|
||||
}
|
||||
|
||||
class AddLabelsToBookmarkUseCase: PAddLabelsToBookmarkUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class AddTextToSpeechQueueUseCase {
|
||||
protocol PAddTextToSpeechQueueUseCase {
|
||||
func execute(bookmarkDetail: BookmarkDetail)
|
||||
}
|
||||
|
||||
class AddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
|
||||
private let speechQueue: SpeechQueue
|
||||
|
||||
init(speechQueue: SpeechQueue = .shared) {
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
class CreateBookmarkUseCase {
|
||||
protocol PCreateBookmarkUseCase {
|
||||
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
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class DeleteBookmarkUseCase {
|
||||
protocol PDeleteBookmarkUseCase {
|
||||
func execute(bookmarkId: String) async throws
|
||||
}
|
||||
|
||||
class DeleteBookmarkUseCase: PDeleteBookmarkUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class GetBookmarkArticleUseCase {
|
||||
protocol PGetBookmarkArticleUseCase {
|
||||
func execute(id: String) async throws -> String
|
||||
}
|
||||
|
||||
class GetBookmarkArticleUseCase: PGetBookmarkArticleUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class GetBookmarkUseCase {
|
||||
protocol PGetBookmarkUseCase {
|
||||
func execute(id: String) async throws -> BookmarkDetail
|
||||
}
|
||||
|
||||
class GetBookmarkUseCase: PGetBookmarkUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class GetBookmarksUseCase {
|
||||
protocol PGetBookmarksUseCase {
|
||||
func execute(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
|
||||
}
|
||||
|
||||
class GetBookmarksUseCase: PGetBookmarksUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class GetLabelsUseCase {
|
||||
protocol PGetLabelsUseCase {
|
||||
func execute() async throws -> [BookmarkLabel]
|
||||
}
|
||||
|
||||
class GetLabelsUseCase: PGetLabelsUseCase {
|
||||
private let labelsRepository: PLabelsRepository
|
||||
|
||||
init(labelsRepository: PLabelsRepository) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class LoadSettingsUseCase {
|
||||
protocol PLoadSettingsUseCase {
|
||||
func execute() async throws -> Settings?
|
||||
}
|
||||
|
||||
class LoadSettingsUseCase: PLoadSettingsUseCase {
|
||||
private let authRepository: PAuthRepository
|
||||
|
||||
init(authRepository: PAuthRepository) {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
class LoginUseCase {
|
||||
|
||||
protocol PLoginUseCase {
|
||||
func execute(endpoint: String, username: String, password: String) async throws -> User
|
||||
}
|
||||
|
||||
class LoginUseCase: PLoginUseCase {
|
||||
private let repository: PAuthRepository
|
||||
|
||||
init(repository: PAuthRepository) {
|
||||
|
||||
@ -7,11 +7,11 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol LogoutUseCaseProtocol {
|
||||
protocol PLogoutUseCase {
|
||||
func execute() async throws
|
||||
}
|
||||
|
||||
class LogoutUseCase: LogoutUseCaseProtocol {
|
||||
class LogoutUseCase: PLogoutUseCase {
|
||||
private let settingsRepository: SettingsRepository
|
||||
private let tokenManager: TokenManager
|
||||
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class ReadBookmarkUseCase {
|
||||
protocol PReadBookmarkUseCase {
|
||||
func execute(bookmarkDetail: BookmarkDetail)
|
||||
}
|
||||
|
||||
class ReadBookmarkUseCase: PReadBookmarkUseCase {
|
||||
private let addToSpeechQueue: AddTextToSpeechQueueUseCase
|
||||
|
||||
init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
class RemoveLabelsFromBookmarkUseCase {
|
||||
protocol PRemoveLabelsFromBookmarkUseCase {
|
||||
func execute(bookmarkId: String, labels: [String]) async throws
|
||||
func execute(bookmarkId: String, label: String) async throws
|
||||
}
|
||||
|
||||
class RemoveLabelsFromBookmarkUseCase: PRemoveLabelsFromBookmarkUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class SaveServerSettingsUseCase {
|
||||
protocol PSaveServerSettingsUseCase {
|
||||
func execute(endpoint: String, username: String, password: String, token: String) async throws
|
||||
}
|
||||
|
||||
class SaveServerSettingsUseCase: PSaveServerSettingsUseCase {
|
||||
private let repository: PSettingsRepository
|
||||
|
||||
init(repository: PSettingsRepository) {
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
class SaveSettingsUseCase {
|
||||
protocol 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 SaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
private let settingsRepository: PSettingsRepository
|
||||
|
||||
init(settingsRepository: PSettingsRepository) {
|
||||
@ -44,4 +53,16 @@ class SaveSettingsUseCase {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
class SearchBookmarksUseCase {
|
||||
protocol PSearchBookmarksUseCase {
|
||||
func execute(search: String) async throws -> BookmarksPage
|
||||
}
|
||||
|
||||
class SearchBookmarksUseCase: PSearchBookmarksUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
class UpdateBookmarkUseCase {
|
||||
protocol 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 UpdateBookmarkUseCase: PUpdateBookmarkUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
@ -51,4 +63,4 @@ class UpdateBookmarkUseCase {
|
||||
let request = BookmarkUpdateRequest.removeLabels(labels)
|
||||
try await execute(bookmarkId: bookmarkId, updateRequest: request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,16 +13,23 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>green2</string>
|
||||
<key>UIImageName</key>
|
||||
<string>readeck</string>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSExceptionRequiresForwardSecrecy</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>splashBackground</string>
|
||||
<key>UIImageName</key>
|
||||
<string>splash</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
67
readeck/Splash.storyboard
Normal file
@ -0,0 +1,67 @@
|
||||
<?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>
|
||||
@ -1,8 +1,11 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AddBookmarkView: View {
|
||||
@State private var viewModel = AddBookmarkViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
||||
@State private var keyboardHeight: CGFloat = 0
|
||||
|
||||
init(prefilledURL: String? = nil, prefilledTitle: String? = nil) {
|
||||
_viewModel = State(initialValue: AddBookmarkViewModel())
|
||||
@ -17,212 +20,230 @@ struct AddBookmarkView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
// Scrollable Form Content
|
||||
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))
|
||||
formContent
|
||||
bottomActionArea
|
||||
}
|
||||
.navigationTitle("New Bookmark")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Schließen") {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
viewModel.clearForm()
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.alert("Fehler", isPresented: $viewModel.showErrorAlert) {
|
||||
.alert("Error", isPresented: $viewModel.showErrorAlert) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "Unbekannter Fehler")
|
||||
Text(viewModel.errorMessage ?? "Unknown error")
|
||||
}
|
||||
.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 {
|
||||
viewModel.checkClipboard()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadAllLabels()
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.clearForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Styles
|
||||
|
||||
struct CustomTextFieldStyle: TextFieldStyle {
|
||||
func _body(configuration: TextField<Self._Label>) -> some View {
|
||||
configuration
|
||||
|
||||
// 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))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||
)
|
||||
.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
|
||||
|
||||
struct SecondaryButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
|
||||
@ -3,17 +3,37 @@ import UIKit
|
||||
|
||||
@Observable
|
||||
class AddBookmarkViewModel {
|
||||
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
|
||||
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
|
||||
|
||||
// MARK: - Form Data
|
||||
var url: String = ""
|
||||
var title: 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 errorMessage: String?
|
||||
var showErrorAlert: Bool = false
|
||||
var hasCreated: Bool = false
|
||||
|
||||
// MARK: - Clipboard Management
|
||||
|
||||
var clipboardURL: String?
|
||||
var showClipboardButton: Bool = false
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var isValid: Bool {
|
||||
!url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
@ -27,6 +47,82 @@ class AddBookmarkViewModel {
|
||||
.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
|
||||
func createBookmark() async {
|
||||
guard isValid else { return }
|
||||
@ -38,7 +134,7 @@ class AddBookmarkViewModel {
|
||||
do {
|
||||
let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let labels = parsedLabels
|
||||
let labels = Array(selectedLabels)
|
||||
|
||||
let request = CreateBookmarkRequest(
|
||||
url: cleanURL,
|
||||
@ -48,7 +144,7 @@ class AddBookmarkViewModel {
|
||||
|
||||
let message = try await createBookmarkUseCase.execute(createRequest: request)
|
||||
|
||||
// Optional: Zeige die Server-Nachricht an
|
||||
// Optional: Show the server message
|
||||
print("Server response: \(message)")
|
||||
|
||||
clearForm()
|
||||
@ -57,31 +153,52 @@ class AddBookmarkViewModel {
|
||||
errorMessage = error.localizedDescription
|
||||
showErrorAlert = true
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Erstellen des Bookmarks"
|
||||
errorMessage = "Error creating bookmark"
|
||||
showErrorAlert = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// MARK: - Clipboard Management
|
||||
|
||||
func checkClipboard() {
|
||||
guard let clipboardString = UIPasteboard.general.string,
|
||||
URL(string: clipboardString) != nil else {
|
||||
clipboardURL = nil
|
||||
showClipboardButton = false
|
||||
return
|
||||
}
|
||||
|
||||
clipboardURL = clipboardString
|
||||
// Only show clipboard button if the URL is different from current URL
|
||||
let currentURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if clipboardString != currentURL {
|
||||
clipboardURL = clipboardString
|
||||
showClipboardButton = true
|
||||
} else {
|
||||
showClipboardButton = false
|
||||
}
|
||||
}
|
||||
|
||||
func pasteFromClipboard() {
|
||||
guard let clipboardURL = clipboardURL else { return }
|
||||
url = clipboardURL
|
||||
showClipboardButton = false
|
||||
}
|
||||
|
||||
func dismissClipboard() {
|
||||
showClipboardButton = false
|
||||
}
|
||||
|
||||
// MARK: - Form Management
|
||||
|
||||
func clearForm() {
|
||||
url = ""
|
||||
title = ""
|
||||
labelsText = ""
|
||||
selectedLabels.removeAll()
|
||||
searchText = ""
|
||||
clipboardURL = nil
|
||||
showClipboardButton = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,32 +1,114 @@
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
import Combine
|
||||
|
||||
struct BookmarkDetailView: View {
|
||||
let bookmarkId: String
|
||||
@State private var viewModel = BookmarkDetailViewModel()
|
||||
|
||||
// MARK: - States
|
||||
|
||||
@State private var viewModel: BookmarkDetailViewModel
|
||||
@State private var webViewHeight: CGFloat = 300
|
||||
@State private var showingFontSettings = 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 appSettings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
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 {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
ZStack(alignment: .top) {
|
||||
headerView(geometry: geometry)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
||||
titleSection
|
||||
Divider().padding(.horizontal)
|
||||
contentSection
|
||||
Spacer(minLength: 40)
|
||||
archiveSection
|
||||
VStack(spacing: 0) {
|
||||
ProgressView(value: readingProgress)
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.frame(height: 3)
|
||||
GeometryReader { outerGeo in
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.preference(key: ScrollOffsetPreferenceKey.self,
|
||||
value: geo.frame(in: .named("scroll")).minY)
|
||||
}
|
||||
.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)
|
||||
.toolbar {
|
||||
@ -56,11 +138,11 @@ struct BookmarkDetailView: View {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("Schrift-Einstellungen")
|
||||
.navigationTitle("Font Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Fertig") {
|
||||
Button("Done") {
|
||||
showingFontSettings = false
|
||||
}
|
||||
}
|
||||
@ -86,6 +168,9 @@ struct BookmarkDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.readProgress) { _, progress in
|
||||
showJumpToProgressButton = progress > 0 && progress < 100
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||
await viewModel.loadArticleContent(id: bookmarkId)
|
||||
@ -152,13 +237,16 @@ struct BookmarkDetailView: View {
|
||||
private var contentSection: some View {
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
|
||||
webViewHeight = height
|
||||
withAnimation(.easeInOut(duration: 0.1)) {
|
||||
webViewHeight = height
|
||||
}
|
||||
}
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal)
|
||||
.animation(.easeInOut, value: webViewHeight)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Lade Artikel...")
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
@ -167,24 +255,24 @@ struct BookmarkDetailView: View {
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen")
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 32)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var metaInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !viewModel.bookmarkDetail.authors.isEmpty {
|
||||
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Autor:innen: " : "Autor: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
||||
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
||||
}
|
||||
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit")
|
||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
|
||||
|
||||
// Labels section
|
||||
if !viewModel.bookmarkDetail.labels.isEmpty {
|
||||
@ -221,20 +309,22 @@ struct BookmarkDetailView: View {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen")
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
metaRow(icon: "speaker.wave.2") {
|
||||
Button(action: {
|
||||
viewModel.addBookmarkToSpeechQueue()
|
||||
playerUIState.showPlayer()
|
||||
}) {
|
||||
Text("Artikel vorlesen")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
if appSettings.enableTTS {
|
||||
metaRow(icon: "speaker.wave.2") {
|
||||
Button(action: {
|
||||
viewModel.addBookmarkToSpeechQueue()
|
||||
playerUIState.showPlayer()
|
||||
}) {
|
||||
Text("Read article aloud")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -273,31 +363,48 @@ struct BookmarkDetailView: View {
|
||||
let displayFormatter = DateFormatter()
|
||||
displayFormatter.dateStyle = .medium
|
||||
displayFormatter.timeStyle = .short
|
||||
displayFormatter.locale = Locale(identifier: "de_DE")
|
||||
displayFormatter.locale = .autoupdatingCurrent
|
||||
return displayFormatter.string(from: date)
|
||||
}
|
||||
return dateString
|
||||
}
|
||||
|
||||
private var archiveSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Fertig mit Lesen?")
|
||||
VStack(alignment: .center, spacing: 12) {
|
||||
Text("Finished reading?")
|
||||
.font(.headline)
|
||||
.padding(.top, 24)
|
||||
if viewModel.bookmarkDetail.isArchived {
|
||||
Label("Bookmark ist archiviert", systemImage: "archivebox.fill")
|
||||
} else {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.archiveBookmark(id: bookmarkId)
|
||||
await viewModel.toggleFavorite(id: bookmarkId)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "archivebox")
|
||||
Text("Bookmark archivieren")
|
||||
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
|
||||
.foregroundColor(viewModel.bookmarkDetail.isMarked ? .yellow : .gray)
|
||||
Text(viewModel.bookmarkDetail.isMarked ? "Favorite" : "Mark as favorite")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity, maxHeight: 40)
|
||||
.frame(maxHeight: 60)
|
||||
.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)
|
||||
.disabled(viewModel.isLoading)
|
||||
@ -311,10 +418,43 @@ struct BookmarkDetailView: View {
|
||||
.padding(.horizontal)
|
||||
.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 {
|
||||
NavigationView {
|
||||
BookmarkDetailView(bookmarkId: "sample-id")
|
||||
BookmarkDetailView(bookmarkId: "123",
|
||||
viewModel: .init(MockUseCaseFactory()),
|
||||
webViewHeight: 300,
|
||||
showingFontSettings: false,
|
||||
showingLabelsSheet: false,
|
||||
playerUIState: .init())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,29 +1,44 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@Observable
|
||||
class BookmarkDetailViewModel {
|
||||
private let getBookmarkUseCase: GetBookmarkUseCase
|
||||
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
|
||||
private let loadSettingsUseCase: LoadSettingsUseCase
|
||||
private let updateBookmarkUseCase: UpdateBookmarkUseCase
|
||||
private let addTextToSpeechQueueUseCase: AddTextToSpeechQueueUseCase
|
||||
private let getBookmarkUseCase: PGetBookmarkUseCase
|
||||
private let getBookmarkArticleUseCase: PGetBookmarkArticleUseCase
|
||||
private let loadSettingsUseCase: PLoadSettingsUseCase
|
||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
|
||||
|
||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||
var articleContent: String = ""
|
||||
var articleParagraphs: [String] = []
|
||||
var bookmark: Bookmark? = nil
|
||||
var isLoading = false
|
||||
var isLoadingArticle = false
|
||||
var isLoadingArticle = true
|
||||
var errorMessage: String?
|
||||
var settings: Settings?
|
||||
var readProgress: Int = 0
|
||||
|
||||
init() {
|
||||
let factory = DefaultUseCaseFactory.shared
|
||||
private var factory: UseCaseFactory?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>()
|
||||
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
|
||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||
self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase()
|
||||
self.factory = factory
|
||||
|
||||
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
|
||||
@ -34,8 +49,16 @@ class BookmarkDetailViewModel {
|
||||
do {
|
||||
settings = try await loadSettingsUseCase.execute()
|
||||
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 {
|
||||
errorMessage = "Fehler beim Laden des Bookmarks"
|
||||
errorMessage = "Error loading bookmark"
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
@ -49,7 +72,7 @@ class BookmarkDetailViewModel {
|
||||
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
|
||||
processArticleContent()
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Laden des Artikels"
|
||||
errorMessage = "Error loading article"
|
||||
}
|
||||
|
||||
isLoadingArticle = false
|
||||
@ -64,14 +87,14 @@ class BookmarkDetailViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func archiveBookmark(id: String) async {
|
||||
func archiveBookmark(id: String, isArchive: Bool = true) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true)
|
||||
try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: isArchive)
|
||||
bookmarkDetail.isArchived = true
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Archivieren des Bookmarks"
|
||||
errorMessage = "Error archiving bookmark"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
@ -83,6 +106,35 @@ class BookmarkDetailViewModel {
|
||||
|
||||
func addBookmarkToSpeechQueue() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,172 +5,136 @@ struct BookmarkLabelsView: View {
|
||||
@State private var viewModel: BookmarkLabelsViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(bookmarkId: String, initialLabels: [String]) {
|
||||
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) {
|
||||
self.bookmarkId = bookmarkId
|
||||
self._viewModel = State(initialValue: BookmarkLabelsViewModel(initialLabels: initialLabels))
|
||||
self._viewModel = State(initialValue: viewModel ?? BookmarkLabelsViewModel(initialLabels: initialLabels))
|
||||
|
||||
UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(Color.primary)
|
||||
UIPageControl.appearance().pageIndicatorTintColor = UIColor(Color.primary).withAlphaComponent(0.2)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 16) {
|
||||
// Add new label section
|
||||
addLabelSection
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, -16)
|
||||
|
||||
// Current labels section
|
||||
currentLabelsSection
|
||||
|
||||
VStack(spacing: 12) {
|
||||
searchSection
|
||||
availableLabelsSection
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.padding(.vertical)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Labels verwalten")
|
||||
.navigationTitle("Manage Labels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
|
||||
Button("Abbrechen") {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Fertig") {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Fehler", isPresented: $viewModel.showErrorAlert) {
|
||||
.alert("Error", isPresented: $viewModel.showErrorAlert) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "Unbekannter Fehler")
|
||||
Text(viewModel.errorMessage ?? "Unknown error")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadAllLabels()
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var addLabelSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Neues Label hinzufügen")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
TextField("Label eingeben...", text: $viewModel.newLabelText)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onSubmit {
|
||||
Task {
|
||||
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
)
|
||||
// MARK: - View Components
|
||||
|
||||
@ViewBuilder
|
||||
private var searchSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
searchField
|
||||
customTagSuggestion
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.disabled(viewModel.newLabelText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemGroupedBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private var currentLabelsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@ViewBuilder
|
||||
private var customTagSuggestion: some View {
|
||||
if !viewModel.searchText.isEmpty &&
|
||||
!viewModel.filteredLabels.contains(where: { $0.name.lowercased() == viewModel.searchText.lowercased() }) {
|
||||
HStack {
|
||||
Text("Aktuelle Labels")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
Text("Add new tag:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.searchText)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
|
||||
}
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var availableLabelsSection: some View {
|
||||
TagManagementView(
|
||||
allLabels: viewModel.allLabels,
|
||||
selectedLabels: Set(viewModel.currentLabels),
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: viewModel.isInitialLoading,
|
||||
availableLabelPages: viewModel.availableLabelPages,
|
||||
filteredLabels: viewModel.filteredLabels,
|
||||
onAddCustomTag: {
|
||||
Task {
|
||||
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
|
||||
}
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
Task {
|
||||
await viewModel.toggleLabel(for: bookmarkId, label: label)
|
||||
}
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
Task {
|
||||
await viewModel.removeLabel(from: bookmarkId, label: label)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"])
|
||||
}
|
||||
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"], viewModel: .init(MockUseCaseFactory(), initialLabels: ["test"]))
|
||||
}
|
||||
|
||||
@ -2,17 +2,72 @@ import Foundation
|
||||
|
||||
@Observable
|
||||
class BookmarkLabelsViewModel {
|
||||
private let addLabelsUseCase = DefaultUseCaseFactory.shared.makeAddLabelsToBookmarkUseCase()
|
||||
private let removeLabelsUseCase = DefaultUseCaseFactory.shared.makeRemoveLabelsFromBookmarkUseCase()
|
||||
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
|
||||
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
|
||||
private let getLabelsUseCase: PGetLabelsUseCase
|
||||
|
||||
var isLoading = false
|
||||
var isInitialLoading = false
|
||||
var errorMessage: String?
|
||||
var showErrorAlert = false
|
||||
var currentLabels: [String] = []
|
||||
var currentLabels: [String] = [] {
|
||||
didSet {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
var newLabelText = ""
|
||||
var searchText = "" {
|
||||
didSet {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
|
||||
init(initialLabels: [String] = []) {
|
||||
var allLabels: [BookmarkLabel] = [] {
|
||||
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.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
|
||||
@ -21,19 +76,20 @@ class BookmarkLabelsViewModel {
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
|
||||
// Update local labels
|
||||
currentLabels.append(contentsOf: labels)
|
||||
currentLabels = Array(Set(currentLabels)) // Remove duplicates
|
||||
|
||||
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
|
||||
} catch let error as BookmarkUpdateError {
|
||||
errorMessage = error.localizedDescription
|
||||
showErrorAlert = true
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Hinzufügen der Labels"
|
||||
errorMessage = "Error adding labels"
|
||||
showErrorAlert = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
calculatePages()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -43,6 +99,7 @@ class BookmarkLabelsViewModel {
|
||||
|
||||
await addLabels(to: bookmarkId, labels: [trimmedLabel])
|
||||
newLabelText = ""
|
||||
searchText = ""
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -58,11 +115,12 @@ class BookmarkLabelsViewModel {
|
||||
errorMessage = error.localizedDescription
|
||||
showErrorAlert = true
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Entfernen der Labels"
|
||||
errorMessage = "Error removing labels"
|
||||
showErrorAlert = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
calculatePages()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -78,9 +136,36 @@ class BookmarkLabelsViewModel {
|
||||
} else {
|
||||
await addLabel(to: bookmarkId, label: label)
|
||||
}
|
||||
|
||||
calculatePages()
|
||||
}
|
||||
|
||||
func updateLabels(_ labels: [String]) {
|
||||
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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,19 +13,46 @@ struct BookmarkCardView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
AsyncImage(url: imageURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
} placeholder: {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AsyncImage(url: imageURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
} placeholder: {
|
||||
|
||||
Image(R.image.placeholder.name)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Image(R.image.placeholder.name)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
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) {
|
||||
Text(bookmark.title)
|
||||
@ -37,7 +64,7 @@ struct BookmarkCardView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
|
||||
// Veröffentlichungsdatum
|
||||
// Published date
|
||||
if let publishedDate = formattedPublishedDate {
|
||||
HStack {
|
||||
Label(publishedDate, systemImage: "calendar")
|
||||
@ -58,8 +85,7 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
|
||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Seite") + " öffnen", systemImage: "safari")
|
||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||
.onTapGesture {
|
||||
SafariUtil.openInSafari(url: bookmark.url)
|
||||
}
|
||||
@ -68,12 +94,6 @@ struct BookmarkCardView: View {
|
||||
.font(.caption)
|
||||
.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(.bottom, 12)
|
||||
@ -82,20 +102,20 @@ struct BookmarkCardView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button("Löschen", role: .destructive) {
|
||||
Button("Delete", role: .destructive) {
|
||||
onDelete(bookmark)
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
// Archivieren (links)
|
||||
// Archive (left)
|
||||
Button {
|
||||
onArchive(bookmark)
|
||||
} label: {
|
||||
if currentState == .archived {
|
||||
Label("Wiederherstellen", systemImage: "tray.and.arrow.up")
|
||||
Label("Restore", systemImage: "tray.and.arrow.up")
|
||||
} else {
|
||||
Label("Archivieren", systemImage: "archivebox")
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
}
|
||||
.tint(currentState == .archived ? .blue : .orange)
|
||||
@ -103,7 +123,7 @@ struct BookmarkCardView: View {
|
||||
Button {
|
||||
onToggleFavorite(bookmark)
|
||||
} label: {
|
||||
Label(bookmark.isMarked ? "Entfernen" : "Favorit",
|
||||
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
||||
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
||||
}
|
||||
.tint(bookmark.isMarked ? .gray : .pink)
|
||||
@ -127,7 +147,7 @@ struct BookmarkCardView: View {
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
|
||||
guard let date = formatter.date(from: published) else {
|
||||
// Fallback ohne Millisekunden
|
||||
// Fallback without milliseconds
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||||
guard let fallbackDate = formatter.date(from: published) else {
|
||||
return nil
|
||||
@ -142,42 +162,42 @@ struct BookmarkCardView: View {
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Heute
|
||||
// Today
|
||||
if calendar.isDateInToday(date) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return "Heute, \(formatter.string(from: date))"
|
||||
return "Today, \(formatter.string(from: date))"
|
||||
}
|
||||
|
||||
// Gestern
|
||||
// Yesterday
|
||||
if calendar.isDateInYesterday(date) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return "Gestern, \(formatter.string(from: date))"
|
||||
return "Yesterday, \(formatter.string(from: date))"
|
||||
}
|
||||
|
||||
// Diese Woche
|
||||
// This week
|
||||
if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
// Dieses Jahr
|
||||
// This year
|
||||
if calendar.isDate(date, equalTo: now, toGranularity: .year) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "d. MMM, HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
// Andere Jahre
|
||||
// Other years
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "d. MMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private var imageURL: URL? {
|
||||
// Bevorzuge image, dann thumbnail, dann icon
|
||||
// Prioritize image, then thumbnail, then icon
|
||||
if let imageUrl = bookmark.resources.image?.src {
|
||||
return URL(string: imageUrl)
|
||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
||||
@ -203,3 +223,12 @@ struct IconBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
BookmarkCardView(bookmark: .mock, currentState: .all) { _ in
|
||||
|
||||
} onDelete: { _ in
|
||||
|
||||
} onToggleFavorite: { _ in
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,12 +6,13 @@ struct BookmarksView: View {
|
||||
|
||||
// MARK: States
|
||||
|
||||
@State private var viewModel = BookmarksViewModel()
|
||||
@State private var viewModel: BookmarksViewModel
|
||||
@State private var showingAddBookmark = false
|
||||
@State private var selectedBookmarkId: String?
|
||||
@State private var showingAddBookmarkFromShare = false
|
||||
@State private var shareURL = ""
|
||||
@State private var shareTitle = ""
|
||||
@State private var bookmarkToDelete: Bookmark? = nil
|
||||
|
||||
let state: BookmarkState
|
||||
let type: [BookmarkType]
|
||||
@ -19,23 +20,49 @@ struct BookmarksView: View {
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
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
|
||||
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
@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 {
|
||||
ZStack {
|
||||
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
|
||||
ProgressView("Lade \(state.displayName)...")
|
||||
VStack(spacing: 20) {
|
||||
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 {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
||||
@ -62,9 +89,7 @@ struct BookmarksView: View {
|
||||
}
|
||||
},
|
||||
onDelete: { bookmark in
|
||||
Task {
|
||||
await viewModel.deleteBookmark(bookmark: bookmark)
|
||||
}
|
||||
bookmarkToDelete = bookmark
|
||||
},
|
||||
onToggleFavorite: { bookmark in
|
||||
Task {
|
||||
@ -95,17 +120,17 @@ struct BookmarksView: View {
|
||||
.overlay {
|
||||
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading {
|
||||
ContentUnavailableView(
|
||||
"Keine Bookmarks",
|
||||
"No bookmarks",
|
||||
systemImage: "bookmark",
|
||||
description: Text(
|
||||
"Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden."
|
||||
"No bookmarks found in \(state.displayName.lowercased())."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FAB Button - nur bei "Ungelesen" anzeigen
|
||||
// FAB Button - only show for "Unread"
|
||||
if state == .unread || state == .all {
|
||||
VStack {
|
||||
Spacer()
|
||||
@ -147,13 +172,18 @@ struct BookmarksView: View {
|
||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||
}
|
||||
)
|
||||
/*.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||
Button("OK", role: .cancel) {
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}*/
|
||||
.alert(item: $bookmarkToDelete) { bookmark in
|
||||
Alert(
|
||||
title: Text("Delete Bookmark"),
|
||||
message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await viewModel.deleteBookmark(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||
@ -163,14 +193,21 @@ struct BookmarksView: View {
|
||||
// Refresh bookmarks when sheet is dismissed
|
||||
if oldValue && !newValue {
|
||||
Task {
|
||||
await viewModel.loadBookmarks(state: state, type: type)
|
||||
// Wait a bit for the server to process the new bookmark
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
||||
|
||||
await viewModel.refreshBookmarks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// String Identifiable Extension für navigationDestination
|
||||
extension String: Identifiable {
|
||||
public var id: String { self }
|
||||
#Preview {
|
||||
BookmarksView(
|
||||
viewModel: .init(MockUseCaseFactory()),
|
||||
state: .archived,
|
||||
type: [.article],
|
||||
selectedBookmark: .constant(nil),
|
||||
tag: nil)
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@ import SwiftUI
|
||||
|
||||
@Observable
|
||||
class BookmarksViewModel {
|
||||
private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBookmarksUseCase()
|
||||
private let updateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase()
|
||||
private let deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase()
|
||||
private let getBooksmarksUseCase: PGetBookmarksUseCase
|
||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
||||
|
||||
var bookmarks: BookmarksPage?
|
||||
var isLoading = false
|
||||
@ -32,7 +32,11 @@ class BookmarksViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
|
||||
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
|
||||
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
@ -57,8 +61,6 @@ class BookmarksViewModel {
|
||||
self.shareTitle = userInfo["title"] as? String ?? ""
|
||||
self.showingAddBookmarkFromShare = true
|
||||
}
|
||||
|
||||
print("Received share notification - URL: \(url)")
|
||||
}
|
||||
|
||||
private func throttleSearch() {
|
||||
@ -83,8 +85,8 @@ class BookmarksViewModel {
|
||||
currentType = type
|
||||
currentTag = tag
|
||||
|
||||
offset = 0 // Offset zurücksetzen
|
||||
hasMoreData = true // Pagination zurücksetzen
|
||||
offset = 0
|
||||
hasMoreData = true
|
||||
|
||||
do {
|
||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||
@ -96,9 +98,9 @@ class BookmarksViewModel {
|
||||
tag: tag
|
||||
)
|
||||
bookmarks = newBookmarks
|
||||
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // Prüfen, ob weitere Daten verfügbar sind
|
||||
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Laden der Bookmarks"
|
||||
errorMessage = "Error loading bookmarks"
|
||||
bookmarks = nil
|
||||
}
|
||||
|
||||
@ -107,23 +109,24 @@ class BookmarksViewModel {
|
||||
|
||||
@MainActor
|
||||
func loadMoreBookmarks() async {
|
||||
guard !isLoading && hasMoreData else { return } // Verhindern, dass mehrfach geladen wird
|
||||
guard !isLoading && hasMoreData else { return } // prevent multiple loads
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
offset += limit // Offset erhöhen
|
||||
offset += limit // inc. offset
|
||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||
state: currentState,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
search: nil,
|
||||
type: currentType,
|
||||
tag: currentTag)
|
||||
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
|
||||
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Nachladen der Bookmarks"
|
||||
errorMessage = "Error loading more bookmarks"
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
@ -142,11 +145,10 @@ class BookmarksViewModel {
|
||||
isArchived: !bookmark.isArchived
|
||||
)
|
||||
|
||||
// Liste aktualisieren
|
||||
await loadBookmarks(state: currentState)
|
||||
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Archivieren des Bookmarks"
|
||||
errorMessage = "Error archiving bookmark"
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,26 +160,21 @@ class BookmarksViewModel {
|
||||
isMarked: !bookmark.isMarked
|
||||
)
|
||||
|
||||
// Liste aktualisieren
|
||||
await loadBookmarks(state: currentState)
|
||||
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Markieren des Bookmarks"
|
||||
errorMessage = "Error marking bookmark"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deleteBookmark(bookmark: Bookmark) async {
|
||||
do {
|
||||
// Echtes Löschen über API statt nur als gelöscht markieren
|
||||
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
||||
|
||||
// Lokal aus der Liste entfernen (optimistische Update)
|
||||
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
|
||||
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Löschen des Bookmarks"
|
||||
// Bei Fehler die Liste neu laden, um konsistenten Zustand zu haben
|
||||
errorMessage = "Error deleting bookmark"
|
||||
await loadBookmarks(state: currentState)
|
||||
}
|
||||
}
|
||||
|
||||
18
readeck/UI/Components/Constants.swift
Normal file
@ -0,0 +1,18 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
21
readeck/UI/Components/CustomTextFieldStyle.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
85
readeck/UI/Components/RButton.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@ -14,4 +14,8 @@ struct SectionHeader: View {
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SectionHeader(title: "hello", icon: "person.circle")
|
||||
}
|
||||
@ -15,4 +15,4 @@ struct StatView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
212
readeck/UI/Components/TagManagementView.swift
Normal file
@ -0,0 +1,212 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
readeck/UI/Components/UnifiedLabelChip.swift
Normal file
@ -0,0 +1,97 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
@ -5,6 +5,7 @@ struct WebView: UIViewRepresentable {
|
||||
let htmlContent: String
|
||||
let settings: Settings
|
||||
let onHeightChange: (CGFloat) -> Void
|
||||
var onScroll: ((Double) -> Void)? = nil
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
@ -16,7 +17,9 @@ struct WebView: UIViewRepresentable {
|
||||
|
||||
// Message Handler hier einmalig hinzufügen
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
|
||||
return webView
|
||||
}
|
||||
@ -24,6 +27,7 @@ struct WebView: UIViewRepresentable {
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
|
||||
let isDarkMode = colorScheme == .dark
|
||||
|
||||
@ -216,14 +220,19 @@ struct WebView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
window.addEventListener('load', updateHeight);
|
||||
setTimeout(updateHeight, 100);
|
||||
setTimeout(updateHeight, 500);
|
||||
setTimeout(updateHeight, 1000);
|
||||
|
||||
// Höhe bei Bild-Ladevorgängen aktualisieren
|
||||
document.querySelectorAll('img').forEach(img => {
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
@ -260,6 +269,8 @@ struct WebView: UIViewRepresentable {
|
||||
|
||||
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||||
var onHeightChange: ((CGFloat) -> Void)?
|
||||
var onScroll: ((Double) -> Void)?
|
||||
var hasHeightUpdate: Bool = false
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
@ -275,12 +286,16 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
if message.name == "heightUpdate", let height = message.body as? CGFloat {
|
||||
DispatchQueue.main.async {
|
||||
self.onHeightChange?(height)
|
||||
if self.hasHeightUpdate == false {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
10
readeck/UI/Extension/StringExtension.swift
Normal file
@ -0,0 +1,10 @@
|
||||
//
|
||||
// File.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 18.07.25.
|
||||
//
|
||||
|
||||
extension String: @retroactive Identifiable {
|
||||
public var id: String { self }
|
||||
}
|
||||
100
readeck/UI/Factory/DefaultUseCaseFactory.swift
Normal file
@ -0,0 +1,100 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
188
readeck/UI/Factory/MockUseCaseFactory.swift
Normal file
@ -0,0 +1,188 @@
|
||||
//
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
135
readeck/UI/Factory/article.html
Normal file
@ -0,0 +1,135 @@
|
||||
<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, it’s critical to <strong>isolate SwiftData behind an abstraction</strong>.</p>
|
||||
|
||||
<p>
|
||||
In this tutorial, we’ll focus on how to achieve this isolation by applying SOLID principles, with a special emphasis on the Dependency Inversion Principle. We’ll show how to decouple SwiftData from the view and the view model, making your app cleaner, safer, and future-proof, ensuring your app'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'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'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, we’ll 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, let’s 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>. We’ll name this protocol PersonDataStore:</p><pre><code>public protocol PersonDataStore {
|
||||
func fetchAll() throws -> [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 doesn’t 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, let’s implement the persistence logic using <strong>SwiftData</strong>. We’ll 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 person’s 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), we’ll use SwiftData’s ModelContext. We’ll 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>. Here’s how each operation is implemented:</p><p><strong>1. Fetching all persons:</strong></p><pre><code>public func fetchAll() throws -> [Person] {
|
||||
let request = FetchDescriptor<LocalePerson>(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 SwiftData’s 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<LocalePerson>(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, you’d 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 Doesn’t 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>Here’s 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<[Person]> = .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 it’s 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've built clearly defined layers, Domain, Infrastructure, and Presentation, it's time to tie everything together into our application. But there's one important rule: the way we compose our application <strong>shouldn'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'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("Failed to initialize ModelContainer: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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, we’ve 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>We’re 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 & 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>
|
||||
@ -2,28 +2,38 @@ import SwiftUI
|
||||
|
||||
struct LabelsView: View {
|
||||
@State var viewModel = LabelsViewModel()
|
||||
@State private var selectedTag: String? = nil
|
||||
@State private var selectedBookmark: Bookmark? = nil
|
||||
@Binding var selectedTag: BookmarkLabel?
|
||||
|
||||
init(viewModel: LabelsViewModel = LabelsViewModel(), selectedTag: Binding<BookmarkLabel?>) {
|
||||
self.viewModel = viewModel
|
||||
self._selectedTag = selectedTag
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
Text("Fehler: \(errorMessage)")
|
||||
Text("Error: \(errorMessage)")
|
||||
.foregroundColor(.red)
|
||||
} else {
|
||||
List {
|
||||
ForEach(viewModel.labels, id: \.href) { label in
|
||||
NavigationLink {
|
||||
BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name)
|
||||
.navigationTitle("\(label.name) (\(label.count))")
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label.name)
|
||||
Spacer()
|
||||
Text("\(label.count)")
|
||||
.foregroundColor(.secondary)
|
||||
if UIDevice.isPhone {
|
||||
NavigationLink {
|
||||
BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name)
|
||||
.navigationTitle("\(label.name) (\(label.count))")
|
||||
} label: {
|
||||
ButtonLabel(label)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
selectedTag = nil
|
||||
DispatchQueue.main.async {
|
||||
selectedTag = label
|
||||
}
|
||||
} label: {
|
||||
ButtonLabel(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,4 +46,14 @@ struct LabelsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ButtonLabel(_ label: BookmarkLabel) -> some View {
|
||||
HStack {
|
||||
Text(label.name)
|
||||
Spacer()
|
||||
Text("\(label.count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,18 @@ import Observation
|
||||
|
||||
@Observable
|
||||
class LabelsViewModel {
|
||||
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
|
||||
private let getLabelsUseCase: PGetLabelsUseCase
|
||||
|
||||
var labels: [BookmarkLabel] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String? = nil
|
||||
var isLoading: Bool
|
||||
var errorMessage: String?
|
||||
|
||||
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
|
||||
func loadLabels() async {
|
||||
@ -16,7 +23,7 @@ class LabelsViewModel {
|
||||
do {
|
||||
labels = try await getLabelsUseCase.execute()
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Laden der Labels"
|
||||
errorMessage = "Error loading labels"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@ -14,13 +14,13 @@ enum BookmarkState: String, CaseIterable {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return "Alle"
|
||||
return "All"
|
||||
case .unread:
|
||||
return "Ungelesen"
|
||||
return "Unread"
|
||||
case .favorite:
|
||||
return "Favoriten"
|
||||
return "Favorites"
|
||||
case .archived:
|
||||
return "Archiv"
|
||||
return "Archive"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ struct PadSidebarView: View {
|
||||
@State private var selectedBookmark: Bookmark?
|
||||
@State private var selectedTag: BookmarkLabel?
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
||||
|
||||
@ -53,8 +54,11 @@ struct PadSidebarView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg))
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.top, 8)
|
||||
|
||||
if appSettings.enableTTS {
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color(R.color.menu_sidebar_bg))
|
||||
@ -82,7 +86,16 @@ struct PadSidebarView: View {
|
||||
case .pictures:
|
||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
|
||||
case .tags:
|
||||
LabelsView()
|
||||
NavigationStack {
|
||||
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)
|
||||
@ -90,7 +103,7 @@ struct PadSidebarView: View {
|
||||
} detail: {
|
||||
if let bookmark = selectedBookmark, selectedTab != .settings {
|
||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||
} else {
|
||||
} else if selectedTab == .settings {
|
||||
Text(selectedTab == .settings ? "" : "Select a bookmark or tag")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
@ -12,7 +12,9 @@ struct PhoneTabView: View {
|
||||
private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings]
|
||||
|
||||
@State private var selectedMoreTab: SidebarTab? = nil
|
||||
@State private var selectedTabIndex: Int = 0
|
||||
@State private var selectedTabIndex: Int = 1
|
||||
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
GlobalPlayerContainerView {
|
||||
@ -28,31 +30,33 @@ struct PhoneTabView: View {
|
||||
}
|
||||
|
||||
NavigationStack {
|
||||
if let selectedTab = selectedMoreTab {
|
||||
tabView(for: selectedTab)
|
||||
.navigationTitle(selectedTab.label)
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
||||
NavigationLink {
|
||||
tabView(for: tab)
|
||||
.navigationTitle(tab.label)
|
||||
} label: {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
List(moreTabs, id: \.self) { tab in
|
||||
|
||||
NavigationLink {
|
||||
tabView(for: tab)
|
||||
.navigationTitle(tab.label)
|
||||
.onDisappear {
|
||||
// tags and search handle navigation by own
|
||||
if tab != .tags && tab != .search {
|
||||
selectedMoreTab = nil
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
.navigationTitle("Mehr")
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.bottom, 16)
|
||||
} label: {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
.navigationTitle("More")
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
|
||||
if appSettings.enableTTS {
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Mehr", systemImage: "ellipsis")
|
||||
Label("More", systemImage: "ellipsis")
|
||||
}
|
||||
.tag(mainTabs.count)
|
||||
.onAppear {
|
||||
@ -87,7 +91,7 @@ struct PhoneTabView: View {
|
||||
case .pictures:
|
||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
||||
case .tags:
|
||||
LabelsView()
|
||||
LabelsView(selectedTag: .constant(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,10 +13,10 @@ struct PlayerQueueResumeButton: View {
|
||||
}) {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Vorlese-Queue")
|
||||
Text("Read-aloud Queue")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(queue.queueItems.count) Artikel in der Queue")
|
||||
Text("\(queue.queueItems.count) articles in the queue")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
@ -25,7 +25,7 @@ struct PlayerQueueResumeButton: View {
|
||||
playerViewModel.resume()
|
||||
playerUIState.showPlayer()
|
||||
}) {
|
||||
Text("Weiterhören")
|
||||
Text("Resume listening")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 14)
|
||||
@ -48,4 +48,4 @@ struct PlayerQueueResumeButton: View {
|
||||
.animation(.spring(), value: queue.hasItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,14 +13,14 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: return "All"
|
||||
case .unread: return "Ungelesen"
|
||||
case .favorite: return "Favoriten"
|
||||
case .archived: return "Archiv"
|
||||
case .search: return "Suche"
|
||||
case .settings: return "Einstellungen"
|
||||
case .article: return "Artikel"
|
||||
case .unread: return "Unread"
|
||||
case .favorite: return "Favorites"
|
||||
case .archived: return "Archive"
|
||||
case .search: return "Search"
|
||||
case .settings: return "Settings"
|
||||
case .article: return "Articles"
|
||||
case .videos: return "Videos"
|
||||
case .pictures: return "Bilder"
|
||||
case .pictures: return "Pictures"
|
||||
case .tags: return "Tags"
|
||||
}
|
||||
}
|
||||
|
||||
33
readeck/UI/Models/AppSettings.swift
Normal file
@ -0,0 +1,33 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -45,8 +45,16 @@ struct SearchBookmarksView: View {
|
||||
|
||||
if let bookmarks = viewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
||||
List(bookmarks) { bookmark in
|
||||
Button(action: {
|
||||
|
||||
NavigationLink {
|
||||
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 {
|
||||
selectedBookmarkId = bookmark.id
|
||||
} else {
|
||||
@ -66,6 +74,7 @@ struct SearchBookmarksView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
*/
|
||||
}
|
||||
.listStyle(.plain)
|
||||
} else if !viewModel.isLoading && viewModel.bookmarks != nil {
|
||||
|
||||
@ -8,7 +8,11 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FontSettingsView: View {
|
||||
@State private var viewModel = FontSettingsViewModel()
|
||||
@State private var viewModel: FontSettingsViewModel
|
||||
|
||||
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
@ -18,16 +22,16 @@ struct FontSettingsView: View {
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Text("Schrift")
|
||||
Text("Font")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
|
||||
// Font Family Picker
|
||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||
Text("Schriftart")
|
||||
Text("Font family")
|
||||
.font(.headline)
|
||||
Picker("Schriftart", selection: $viewModel.selectedFontFamily) {
|
||||
Picker("Font family", selection: $viewModel.selectedFontFamily) {
|
||||
ForEach(FontFamily.allCases, id: \.self) { family in
|
||||
Text(family.displayName).tag(family)
|
||||
}
|
||||
@ -44,9 +48,9 @@ struct FontSettingsView: View {
|
||||
|
||||
// Font Size Picker
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Schriftgröße")
|
||||
Text("Font size")
|
||||
.font(.headline)
|
||||
Picker("Schriftgröße", selection: $viewModel.selectedFontSize) {
|
||||
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
||||
ForEach(FontSize.allCases, id: \.self) { size in
|
||||
Text(size.displayName).tag(size)
|
||||
}
|
||||
@ -61,7 +65,7 @@ struct FontSettingsView: View {
|
||||
|
||||
// Font Preview
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Vorschau")
|
||||
Text("Preview")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@ -96,5 +100,7 @@ struct FontSettingsView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
FontSettingsView()
|
||||
}
|
||||
FontSettingsView(viewModel: .init(
|
||||
factory: MockUseCaseFactory())
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ import SwiftUI
|
||||
|
||||
@Observable
|
||||
class FontSettingsViewModel {
|
||||
private let saveSettingsUseCase: SaveSettingsUseCase
|
||||
private let loadSettingsUseCase: LoadSettingsUseCase
|
||||
private let saveSettingsUseCase: PSaveSettingsUseCase
|
||||
private let loadSettingsUseCase: PLoadSettingsUseCase
|
||||
|
||||
// MARK: - Font Settings
|
||||
var selectedFontFamily: FontFamily = .system
|
||||
@ -63,8 +63,7 @@ class FontSettingsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
let factory = DefaultUseCaseFactory.shared
|
||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
}
|
||||
@ -77,7 +76,7 @@ class FontSettingsViewModel {
|
||||
selectedFontSize = settings.fontSize ?? .medium
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Laden der Schrift-Einstellungen"
|
||||
errorMessage = "Error loading font settings"
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,9 +87,9 @@ class FontSettingsViewModel {
|
||||
selectedFontFamily: selectedFontFamily,
|
||||
selectedFontSize: selectedFontSize
|
||||
)
|
||||
successMessage = "Schrift-Einstellungen gespeichert"
|
||||
successMessage = "Font settings saved"
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Speichern der Schrift-Einstellungen"
|
||||
errorMessage = "Error saving font settings"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,24 +8,68 @@
|
||||
import SwiftUI
|
||||
|
||||
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 {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 20) {
|
||||
SettingsServerView()
|
||||
.cardStyle()
|
||||
|
||||
FontSettingsView()
|
||||
.cardStyle()
|
||||
|
||||
SettingsGeneralView()
|
||||
.cardStyle()
|
||||
|
||||
SettingsServerView()
|
||||
.cardStyle()
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGroupedBackground))
|
||||
|
||||
AppInfo()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("Einstellungen")
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Settings")
|
||||
.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
|
||||
|
||||
@ -6,14 +6,17 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
// SectionHeader wird jetzt zentral importiert
|
||||
|
||||
struct SettingsGeneralView: View {
|
||||
@State private var viewModel = SettingsGeneralViewModel()
|
||||
@State private var viewModel: SettingsGeneralViewModel
|
||||
|
||||
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Allgemeine Einstellungen", icon: "gear")
|
||||
SectionHeader(title: "General Settings", icon: "gear")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Theme
|
||||
@ -26,38 +29,58 @@ struct SettingsGeneralView: View {
|
||||
}
|
||||
}
|
||||
.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
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Sync-Einstellungen")
|
||||
Text("Sync Settings")
|
||||
.font(.headline)
|
||||
Toggle("Automatischer Sync", isOn: $viewModel.autoSyncEnabled)
|
||||
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
if viewModel.autoSyncEnabled {
|
||||
HStack {
|
||||
Text("Sync-Intervall")
|
||||
Text("Sync interval")
|
||||
Spacer()
|
||||
Stepper("\(viewModel.syncInterval) Minuten", value: $viewModel.syncInterval, in: 1...60)
|
||||
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reading Settings
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Leseeinstellungen")
|
||||
Text("Reading Settings")
|
||||
.font(.headline)
|
||||
Toggle("Safari Reader Modus", isOn: $viewModel.enableReaderMode)
|
||||
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
Toggle("Externe Links in In-App Safari öffnen", isOn: $viewModel.openExternalLinksInApp)
|
||||
Toggle("Open external links in in-app Safari", isOn: $viewModel.openExternalLinksInApp)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
Toggle("Artikel automatisch als gelesen markieren", isOn: $viewModel.autoMarkAsRead)
|
||||
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
}
|
||||
|
||||
// Data Management
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Datenmanagement")
|
||||
Text("Data Management")
|
||||
.font(.headline)
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
@ -67,7 +90,7 @@ struct SettingsGeneralView: View {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
Text("Cache leeren")
|
||||
Text("Clear cache")
|
||||
.foregroundColor(.red)
|
||||
Spacer()
|
||||
}
|
||||
@ -80,53 +103,13 @@ struct SettingsGeneralView: View {
|
||||
HStack {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.foregroundColor(.red)
|
||||
Text("Einstellungen zurücksetzen")
|
||||
Text("Reset settings")
|
||||
.foregroundColor(.red)
|
||||
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
|
||||
if let successMessage = viewModel.successMessage {
|
||||
HStack {
|
||||
@ -146,6 +129,8 @@ struct SettingsGeneralView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadGeneralSettings()
|
||||
@ -153,21 +138,8 @@ struct SettingsGeneralView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 "Hell"
|
||||
case .dark: return "Dunkel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
SettingsGeneralView()
|
||||
}
|
||||
SettingsGeneralView(viewModel: .init(
|
||||
MockUseCaseFactory()
|
||||
))
|
||||
}
|
||||
|
||||