Compare commits

...

21 Commits

Author SHA1 Message Date
a09cad5d7e feat: Add intelligent scroll behavior to AddBookmarkView
- Add enum-based FocusState (AddBookmarkFieldFocus) for cleaner code
- Implement auto-scroll to URL field when focused
- Implement auto-scroll to labels field with 40px offset when focused
- Implement auto-scroll to title field when focused
- Add ScrollViewReader with smooth animations
- Update TagManagementView to support enum-based focus binding
- Add FocusModifier for optional focus state handling
- Improve keyboard handling with proper padding adjustments
2025-08-03 22:40:51 +02:00
61b30112ee feat: Add advanced tag functionality to AddBookmarkView
- Add tag search, pagination and selection with UnifiedLabelChip
- Implement Set-based tag management with validation
- Add keyboard toolbar with Done button
- Remove field labels for compact UI
- Fix duplicate toolbar buttons in ShareBookmarkView
- Update localization strings for tag functionality
2025-07-31 23:06:35 +02:00
3aecdf9ba2 fixed typo 2025-07-30 23:53:51 +02:00
5b2d177f94 feat: Enhanced tag management with unified search and keyboard handling
- Added search functionality to BookmarkLabelsView with real-time filtering
- Implemented custom tag creation with smart suggestions
- Unified search and tag selection in ShareBookmarkView
- Added keyboard toolbar with 'Done' button for extensions
- Implemented notification-based keyboard dismissal for extensions
- Added pagination logic to ShareBookmarkViewModel
- Created selected tags section with remove functionality
- Improved UX with consistent tag management across views
- Added proper keyboard handling for iOS extensions
2025-07-30 23:53:30 +02:00
d036c2e658 feat: Improve AddBookmarkView and loading animations
- Simplify AddBookmarkView header by removing large icon and title
- Add clipboard monitoring with button directly under URL field
- Improve clipboard detection logic with smart URL comparison
- Add dismiss functionality for clipboard suggestions
- Enhance loading animations in BookmarksView:
  - Better initial loading screen with centered animation
  - Use pull-to-refresh instead of overlay for reloading
  - Add 1-second delay after creating bookmark for server sync
- Remove custom Close button styling for default appearance
- Improve overall UX with more natural iOS patterns
2025-07-30 21:18:34 +02:00
03713230b0 feat: optimize label pagination and read progress handling
- Optimize calculatePages() to show single page when ≤12 labels
- Add loading animation for initial label loading only
- Unify label filtering logic in ViewModel instead of UI
- Fix read progress regression by always taking higher value
- Prevent server updates with lower progress values
- Improve UX with better loading states and pagination
2025-07-30 16:09:40 +02:00
1cb87a4fb7 feat: enhance UI with improved label management and splash screen
- Add new logo and splash screen assets with multiple resolutions
- Implement paginated label selection with TabView
- Create UnifiedLabelChip component for consistent label display
- Add manual tag entry functionality with validation
- Refactor BookmarkLabelsViewModel with dependency injection
- Update launch screen configuration and color sets
- Add new localization strings for improved UX
- Improve ShareBookmarkView with better label selection UI
2025-07-29 21:26:32 +02:00
edf1234b53 updated readme 2025-07-24 00:11:41 +02:00
176885442e feat: add delete confirmation for bookmarks and UI improvements
- Add confirmation alert before deleting a bookmark to prevent accidental deletions (BookmarksView)
- Add localized strings for delete confirmation dialog
- Improve layout and logic in BookmarkDetailView (alignment, locale, progress jump)
- Show read progress only for non-archived/non-marked bookmarks (BookmarkCardView)
- Refine WebView: remove debug code, improve scroll/height update logic, disable scroll
2025-07-23 23:58:47 +02:00
dd1b2628b6 updated readme 2025-07-23 22:16:05 +02:00
8e8e67bfe1 UI/UX: Bookmark Detail and CardView improvements
- Progress indicator as a compact circle at the bottom right of the CardView, with percent display
- Jump-to-progress button in detail view, using ScrollPosition logic (removed iOS 17 mention)
- Archive/Unarchive button with flexible parameter and label
- Various bugfixes and refactoring (progress, mock, WebView, strings)
- Improved reading progress logic and display
- Code cleanup: removed debug prints, mutated properties directly
2025-07-23 22:15:21 +02:00
15ce5a223b Add reading progress bar for article view, optimize archive button UX, and improve WebView scroll tracking
- Add a reading progress bar at the top of the article detail view, based on WebView height and ScrollView height
- Remove unused contentHeight logic, use webViewHeight as the single source of truth
- Optimize archive button: show checkmark and 'Archived' after archiving, disable button, and show 'Go Back' button for dismiss
- Enable scrolling in WebView and add JavaScript for scroll progress reporting and debug logs
- Add new localization keys for 'Archived' and 'Go Back'
- Bump project version
2025-07-22 23:27:52 +02:00
bdd7d234a9 Modernize Share UI: SwiftUI card, modern text field, label grid, visual improvements\n\n- Refactor ShareViewController to use SwiftUI (UIHostingController)\n- Add ShareBookmarkView, ShareBookmarkViewModel, LabelGridView\n- Title text field with card style, shadow, clear border, fixed height (38pt)\n- Highlight URL with icon and accentColor\n- Increase label grid to 15 labels, accentColor for selection\n- Compact, centered card instead of fullscreen layout\n- Update various strings and project files 2025-07-22 21:14:42 +02:00
89c1c3c892 updated readme 2025-07-21 23:41:09 +02:00
8d4b08da11 Add TTS feature toggle, refactor settings, and improve UI
- Implemented a toggle for the 'Read Aloud' (TTS) feature in the general settings.
- Refactored AppSettings and PlayerUIState to support TTS enable/disable.
- Updated BookmarkDetailView, PadSidebarView, PhoneTabView, and GlobalPlayerContainerView to respect the TTS setting.
- Added new RButton component for consistent button styling.
- Improved LabelsView to support tag selection on iPad and iPhone.
- Updated SettingsGeneralView and SettingsGeneralViewModel for new TTS logic and removed unused app info code.
- Added app info section to SettingsContainerView.
- Updated SettingsServerView to use English labels and messages.
- Refactored SpeechPlayerViewModel to only initialize TTS when enabled.
- Updated Core Data model to include enableTTS in SettingEntity.
- Removed obsolete files (PersistenceController.swift, old PlayerUIState).
- Various bugfixes, code cleanups, and UI improvements.
2025-07-21 23:37:37 +02:00
387a026e7d Translate UI and error messages from German to English
- BookmarkDetail: All user-facing texts and error messages in BookmarkDetailView, BookmarkDetailViewModel, BookmarkLabelsView, and BookmarkLabelsViewModel translated to English.
- Bookmarks: All UI strings, swipe actions, and error messages in BookmarkCardView, BookmarksView, BookmarksViewModel, and related enums translated to English.
- Labels: All UI and error messages in LabelsView and LabelsViewModel translated to English.
- Menu: All sidebar/tab names, navigation titles, and queue texts in BookmarkState, PhoneTabView, PlayerQueueResumeButton, SidebarTab updated to English.
- Settings: All section headers, toggle labels, button texts, and error/success messages in FontSettingsView, FontSettingsViewModel, SettingsContainerView, SettingsGeneralView, SettingsGeneralViewModel, SettingsServerView, SettingsServerViewModel translated to English.
- SpeechPlayer: All player UI texts, progress, and queue messages in SpeechPlayerView translated to English.

This commit unifies the app language to English for all user-facing areas.
2025-07-18 14:57:45 +02:00
c52d974b05 bumped version and removed debug login 2025-07-18 13:38:30 +02:00
07384215eb Add documentation and tools, refactor BookmarksView for DI, update mocks, and improve project structure
- Add CHANGELOG.md, CODE_OF_CONDUCT.md, and Contribute.md for documentation and community standards
- Add tools/add_spdx_header.sh for SPDX license header management
- Refactor BookmarksView and BookmarksViewModel to support dependency injection via UseCaseFactory
- Add retroactive extension for String: Identifiable in StringExtension.swift
- Update MockUseCaseFactory and MockGetBookmarksUseCase to provide mock data for previews and tests
- Update README.md: add TestFlight info, changelog link, HTTPS/local network note, and move planned features to changelog
2025-07-18 13:36:47 +02:00
930779169b feat: introduce protocol-based UseCase architecture and mock factory
- Add protocols for all UseCases and implement them in their respective classes
- Add DefaultUseCaseFactory and MockUseCaseFactory for dependency injection
- Implement all mock UseCases with dummy data
- Start migration of view models and views to protocol-based UseCase injection (not all migrated yet)
- Refactor previews and some initializers for easier testing
- Move SectionHeader to Components, update server settings UI text
- Add sample article.html for mock content
2025-07-18 00:46:07 +02:00
7861368196 chore: project setup, navigation improvements, and various fixes
- Add Ruby version and Fastlane setup (Gemfile, fastlane/, .ruby-version) for iOS automation and CI/CD
- Add and update Xcode schemes and project configuration
- Update entitlements and Info.plist for app and extension
- Refactor PhoneTabView: improve navigation in 'More' tab, prevent unwanted jumps, preserve deep navigation for Tags/Search
- Update KeychainHelper, BookmarkDetail model, and related ViewModels for bugfixes or enhancements
- Various UI and logic improvements in BookmarkDetail and Search views
2025-07-17 23:45:24 +02:00
530a916552 updated gitignore 2025-07-17 21:34:39 +02:00
111 changed files with 4393 additions and 1435 deletions

3
.gitignore vendored
View File

@ -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
View File

@ -0,0 +1 @@
3.3.0

25
CHANGELOG.md Normal file
View 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
View 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
View 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! Heres how to get started:
1. Fork the repository and clone it locally.
2. Set up your development environment (see below).
3. Create a new branch for your changes.
4. Write clean, well-documented code and follow the existing coding standards.
5. Test your changes (unit and UI tests).
6. Open a pull request and describe your changes.
### 4. Translations
Help translate the iOS app! If you want to add or improve a language, edit the `Localizable.xcstrings` file and submit a pull request.
### 5. Improve Documentation
Help improve the README.md, Contribute.md, or other documentation. Good documentation benefits everyone!
## Setting Up the Development Environment
1. Install Xcode (latest version recommended).
2. Clone the repository:
```
git clone https://codeberg.org/readeck/readeck-ios.git
```
3. Install dependencies (if any, e.g., Swift Packages).
4. Open the project in Xcode: `readeck.xcodeproj`
5. Build and run on a simulator or device.
## Community & Support
- Forum: [Readeck Forum](https://readeck.org/forum)
- Matrix: #readeck:matrix.org
- Issues: In the respective repository on Codeberg or Github
## Code of Conduct
Please follow the [Code of Conduct](https://readeck.org/en/contribute) and be respectful in all interactions.
---
Thank you for helping make Readeck better!

3
Gemfile Normal file
View File

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

227
Gemfile.lock Normal file
View 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

View File

@ -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" : {
}
},

View File

@ -1,4 +1,4 @@
# ReadKeep iOS App
# Readeck iOS App
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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

View 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 = ""
}
}
}

View 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)
}
}
}
}
}
}

View File

@ -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
View 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
}
}
}

View 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
}
}

View File

@ -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>

View 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
View 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
View 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
View 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

View 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]

View File

@ -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" */;

View 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>

View 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>

View File

@ -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"

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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
}
}

View 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
}
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
)
}

View File

@ -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 {

View File

@ -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()

View File

@ -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
)
}

View File

@ -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)
}
}
}

View 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
}
}
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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

View File

@ -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()) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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)
)
}
}

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -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
View 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>

View File

@ -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

View File

@ -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
}
}

View File

@ -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())
}
}

View File

@ -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))
}
}

View File

@ -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"]))
}

View File

@ -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)])
}
}
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View 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
}
}

View 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)
)
}
}

View 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)
}
}

View File

@ -14,4 +14,8 @@ struct SectionHeader: View {
.fontWeight(.bold)
}
}
}
}
#Preview {
SectionHeader(title: "hello", icon: "person.circle")
}

View File

@ -15,4 +15,4 @@ struct StatView: View {
}
.frame(maxWidth: .infinity)
}
}
}

View 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)
}
}
}

View 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()
}

View File

@ -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
}
}

View File

@ -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()
}
}

View 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 }
}

View 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()
}
}

View 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)
)
}

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

View File

@ -2,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)
}
}
}

View File

@ -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
}

View File

@ -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"
}
}

View File

@ -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)
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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"
}
}

View 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
}
}

View File

@ -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 {

View File

@ -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())
)
}

View File

@ -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"
}
}

View File

@ -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

View File

@ -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()
))
}

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