Compare commits

...

115 Commits

Author SHA1 Message Date
db4ce757ee Merge branch 'develop' 2025-11-21 22:10:45 +01:00
59acfa79ac Remove extra blank lines in ReadingSettingsView 2025-11-21 22:10:44 +01:00
6de413376f Clean up documentation and remove debug settings
- Move documentation files from root to docs/ folder
- Remove DEBUG-only settings from ReadingSettingsView (Safari Reader Mode, Auto-mark as read)
2025-11-21 22:07:41 +01:00
d7fcacfd34 Update App Store screenshots for version 1.2.0 2025-11-21 22:05:50 +01:00
ad606d528c Merge branch 'develop' 2025-11-12 21:17:28 +01:00
b98c71b8b3 Replace outdated CHANGELOG.md with RELEASE_NOTES.md references
- Remove outdated CHANGELOG.md file
- Update README.md links to point to the more current RELEASE_NOTES.md
- RELEASE_NOTES.md contains detailed version history and feature descriptions
2025-11-10 21:32:32 +01:00
a3b3863fa3 Refactor tag management system with improved search and real-time sync
- Add CreateLabelUseCase for consistent label creation across app and extension
- Implement TagRepository for Share Extension to persist new labels to Core Data
- Enhance CoreDataTagManagementView with real-time search functionality
- Add automatic tag synchronization on app startup and resume
- Improve Core Data context configuration for better extension support
- Unify label terminology across UI components (tags -> labels)
- Fix label persistence issues in Share Extension
- Add immediate Core Data persistence for newly created labels
- Bump version to 36
2025-11-10 21:29:38 +01:00
4134b41be2 Fix tag scrolling and improve debug logging
- Fix duplicate ID warning in CoreDataTagManagementView by using objectID
- Enhance debug logging system with category filtering
2025-11-08 19:18:56 +01:00
e5d4e6d8a0 Fix tag scrolling and improve debug logging
- Fix duplicate ID warning in CoreDataTagManagementView by using objectID
- Enhance debug logging system with category filtering
2025-11-08 19:18:41 +01:00
4b788650b8 Redesign settings screen with native iOS style
- Move font settings to dedicated detail screen with larger preview
- Add inline explanations directly under each setting
- Reorganize sections: split General into Reading Settings and Sync Settings
- Combine Legal, Privacy and Support into single section
- Move "What's New" to combined Legal/Privacy/Support section
- Redesign app info footer with muted styling and center alignment
- Remove white backgrounds from font preview for better light/dark mode support
2025-11-08 19:12:08 +01:00
f3719fa9d4 Refactor tag management to use Core Data with configurable sorting
This commit introduces a comprehensive refactoring of the tag management
system, replacing the previous API-based approach with a Core Data-first
strategy for improved performance and offline support.

Major Changes:

Tag Management Architecture:
- Add CoreDataTagManagementView using @FetchRequest for reactive updates
- Implement cache-first sync strategy in LabelsRepository
- Create SyncTagsUseCase following Clean Architecture principles
- Add TagSortOrder enum for configurable tag sorting (by count/alphabetically)
- Mark LegacyTagManagementView as deprecated

Share Extension Improvements:
- Replace API-based tag loading with Core Data queries
- Display top 150 tags sorted by usage count
- Remove unnecessary label fetching logic
- Add "Most used tags" localized title
- Improve offline bookmark tag management

Main App Enhancements:
- Add tag sync triggers in AddBookmarkView and BookmarkLabelsView
- Implement user-configurable tag sorting in settings
- Add sort order indicator labels with localization
- Automatic UI updates via SwiftUI @FetchRequest reactivity

Settings & Configuration:
- Add TagSortOrder setting with persistence
- Refactor Settings model structure
- Add FontFamily and FontSize domain models
- Improve settings repository with tag sort order support

Use Case Layer:
- Add SyncTagsUseCase for background tag synchronization
- Update UseCaseFactory with tag sync support
- Add mock implementations for testing

Localization:
- Add German and English translations for:
  - "Most used tags"
  - "Sorted by usage count"
  - "Sorted alphabetically"

Technical Improvements:
- Batch tag updates with conflict detection
- Background sync with silent failure handling
- Reduced server load through local caching
- Better separation of concerns following Clean Architecture
2025-11-08 13:46:40 +01:00
460b05ef34 Add delete annotation feature with swipe gesture
Implemented ability to delete annotations via swipe-to-delete gesture in the annotations list view. Added close button with X icon to dismiss the annotations sheet.

Changes:
- Added DeleteAnnotationUseCase with repository integration
- Extended API with DELETE endpoint for annotations
- Implemented swipe-to-delete in AnnotationsListView
- Added error handling and optimistic UI updates
- Updated toolbar with close button (X icon)
2025-11-01 14:03:39 +01:00
7338db5fab Improve debug logging system
- Redesign logging configuration UI with cleaner list-based navigation
- Replace segmented controls with detailed selection screens for better UX
- Add in-app debug log viewer with filtering and search capabilities
- Implement opt-in logging toggle to reduce device performance impact
- Add log storage system with 1000 entry limit
- Enable log export via share sheet
- Show warning banner when logging is disabled
2025-11-01 13:54:40 +01:00
4b93c605f1 Redesign settings screen with native iOS style
- Refactor all settings views to use List with .insetGrouped style
- Create reusable SettingsRow components for consistent UI
- Separate onboarding flow into dedicated OnboardingServerView
- Consolidate font, theme, and card layout into unified Appearance section
- Add visual card layout previews in dedicated selection screen
- Move "Open links in" option to Appearance with descriptive footer
- Improve settings organization and native iOS feel
2025-10-31 23:39:59 +01:00
589fcdb2b4 Bump build version to 33 2025-10-30 22:39:38 +01:00
4e973751f4 Simplify release notes for regular users 2025-10-30 22:27:14 +01:00
571d61c8e5 Update release notes for markdown rendering improvement 2025-10-30 21:54:02 +01:00
05ae421a40 Add MarkdownUI package and cleanup project structure
- Add swift-markdown-ui package dependency (v2.4.1)
- Remove old Logger.swift (moved to Utils/Logger.swift)
- Remove RELEASE_NOTES.md from Resources (moved to UI/Resources)
- Update German localization strings for settings sections
- Bump build version to 32
2025-10-30 21:50:33 +01:00
85bad35788 Refactor release notes to use MarkdownUI library
- Create MarkdownContentView to encapsulate MarkdownUI rendering
- Replace custom AttributedString markdown parsing with MarkdownUI
- Simplify ReleaseNotesView by removing manual markdown styling
- Improve markdown rendering with proper support for lists, links, and formatting
- Make markdown rendering easily replaceable by keeping it in a dedicated view
2025-10-30 21:48:28 +01:00
db3cbf41d4 Fix URL label translation and formatting
- Add localization keys for "open_url" and "open_original_page" in EN/DE
- Create URLUtil.openUrlLabel() helper function for consistent formatting
- Replace incorrect string concatenation with proper localized labels
- Fix: "example.comopen" now displays as "Open example.com" (EN) or "example.com öffnen" (DE)
- Update BookmarkDetailLegacyView, BookmarkDetailView2, and BookmarkCardView
2025-10-30 21:14:40 +01:00
cdfa6dc4c5 Fix annotation navigation by scrolling outer ScrollView instead of WebView
The JavaScript was executing scrollIntoView() but the WebView itself cannot
scroll (isScrollEnabled = false). Fixed by calculating the annotation's Y
position in the WebView and scrolling the outer ScrollView to the correct
position instead.

Changes:
- WebView.swift: Added onScrollToPosition callback and scrollToPosition
  message handler. JavaScript now calculates and sends annotation position
  to Swift instead of using scrollIntoView().
- NativeWebView.swift: Same changes for iOS 26+ with polling mechanism for
  window.__pendingScrollPosition.
- BookmarkDetailLegacyView.swift: Implemented onScrollToPosition callback
  that calculates final scroll position (header height + annotation position)
  and scrolls the outer ScrollView.
- BookmarkDetailView2.swift: Same implementation as BookmarkDetailLegacyView.
2025-10-30 21:07:13 +01:00
87464943ac bumped build version and version 2025-10-29 22:12:02 +01:00
580b968b89 Merge branch 'main' of http://192.168.188.150:3000/admin/ReadKeep 2025-10-29 22:11:13 +01:00
2dec340c85 Merge branch 'develop' of https://github.com/ilyas-hallak/readeck-ios into develop 2025-10-29 22:10:06 +01:00
9edf984be1 Merge branch 'main' into develop 2025-10-29 22:09:10 +01:00
c610fda731 bumped build version to 30 2025-10-29 22:08:52 +01:00
293ac87b7c Merge branch 'main' into develop 2025-10-29 22:05:16 +01:00
e6a884b160 bumped build version to 30 2025-10-29 22:04:04 +01:00
fef1876297 fix: Improve markdown formatting in release notes view
Add custom AttributedString extension to properly format markdown with correct spacing and header styles. This fixes the compressed appearance of release notes by adding proper line breaks between sections and applying appropriate font sizes to headers.
2025-10-28 22:48:50 +01:00
907cc9220f perf: Optimize label loading for 1000+ labels
Major performance improvements to prevent crashes and lag when working with large label collections:

Main App:
- Switch to Core Data as primary source for labels (instant loading)
- Implement background API sync to keep labels up-to-date
- Add LazyVStack for efficient rendering of large label lists
- Use batch operations instead of individual queries (1 query vs 1000)
- Generate unique IDs for local labels to prevent duplicate warnings

Share Extension:
- Convert getTags() to async with background context
- Add saveTags() method with batch insert support
- Load labels from Core Data first, then sync with API
- Remove duplicate server reachability checks
- Reduce memory usage and prevent UI freezes

Technical Details:
- Labels now load instantly from local cache
- API sync happens in background without blocking UI
- Batch fetch operations for optimal database performance
- Proper error handling for offline scenarios
- Fixed duplicate ID warnings in ForEach loops

Fixes crashes and lag reported by users with 1000+ labels.
2025-10-26 21:24:12 +01:00
c629894611 feat: Show annotations button only when article contains annotations
Add conditional visibility for the annotations button in the toolbar based on whether the loaded article contains any rd-annotation tags.

Changes:
- Add hasAnnotations property to BookmarkDetailViewModel
- Check for <rd-annotation tags when processing article content
- Conditionally show/hide annotations button in BookmarkDetailView2
2025-10-26 21:20:08 +01:00
b77e4e3e9f refactor: Centralize annotation colors and improve color consistency
- Move AnnotationColor enum to Constants.swift for centralized color management
- Add hexColor property to provide hex values for JavaScript overlays
- Add cssColorWithOpacity method for flexible opacity control
- Update NativeWebView and WebView to use centralized color values
- Replace modal color picker with inline overlay for better UX
- Implement annotation creation directly from text selection
- Add API endpoint for creating annotations with selectors
2025-10-25 09:19:49 +02:00
1b9f79bccc fix: Use callJavaScript instead of evaluateJavaScript for WebPage
WebPage in iOS 26 uses callJavaScript method, not evaluateJavaScript.
2025-10-22 15:58:07 +02:00
d1157defbe fix: Resolve WebPage binding error in NativeWebView text selection
Capture webPage locally in Task to avoid @State binding issues when
calling evaluateJavaScript in async context.
2025-10-22 15:54:55 +02:00
a041300b4f feat: Add text selection support for iOS 26+ NativeWebView
Implement text selection detection in NativeWebView:
- Add onTextSelected callback parameter to NativeWebView
- Use JavaScript polling to detect text selections
- Calculate text offsets for precise annotation positioning
- Integrate color picker in BookmarkDetailView2 for iOS 26+
- Match feature parity with legacy WebView implementation

Text selection now works on both WebView implementations.
2025-10-22 15:35:56 +02:00
ec12815a51 feat: Add text selection and annotation creation UI
Implement interactive text annotation feature:
- Add text selection detection via JavaScript in WebView
- Create AnnotationColorPicker with 4 color options (yellow, green, blue, red)
- Integrate color picker sheet in bookmark detail views
- Calculate text offsets for precise annotation positioning
- Add onTextSelected callback for WebView component
- Prepare UI for future API integration

Users can now select text in articles and choose a highlight color.
API integration for persisting annotations will follow.
2025-10-22 15:30:34 +02:00
cf06a3147d feat: Add annotations support with color-coded highlighting
Add comprehensive annotations feature to bookmark detail views:
- Implement annotations list view with date formatting and state machine
- Add CSS-based highlighting for rd-annotation tags in WebView components
- Support Readeck color scheme (yellow, green, blue, red) for annotations
- Enable tap-to-scroll functionality to navigate to selected annotations
- Integrate annotations button in bookmark detail toolbar
- Add API endpoint and repository layer for fetching annotations
2025-10-22 15:25:55 +02:00
47f8f73664 fix: Improve markdown formatting in release notes view
Add custom AttributedString extension to properly format markdown with correct spacing and header styles. This fixes the compressed appearance of release notes by adding proper line breaks between sections and applying appropriate font sizes to headers.
2025-10-19 20:41:04 +02:00
d97e404cc7 fix: Prevent UICollectionView crash from concurrent bookmark list updates
Add isUpdating flag to prevent race conditions when updating the bookmark list.
This fixes crashes that occurred when returning to the app after adding a bookmark
via the share extension while the list was being updated.
2025-10-19 20:40:02 +02:00
6906509aea fix: Remove trailing slash from endpoint instead of adding it
Trailing slash is added elsewhere in the codebase, so here we remove it if present to avoid duplication
2025-10-19 19:40:25 +02:00
afe3d1e261 feat: Add endpoint normalization with validation rules
- Default to https if no scheme provided
- Only accept http and https schemes
- Add trailing slash to path automatically
- Remove query parameters and fragments
- Update endpoint field with normalized value after save
2025-10-19 19:37:35 +02:00
554e223bbc feat: Redesign server settings form with prompt parameters and quick input chips
- Remove redundant field labels, use prompt parameter instead
- Add QuickInputChip component for quick URL entry
- Add chips: http://, https://, 192.168., :8000
- Improve spacing and layout consistency
- Cleaner, more modern UI appearance
2025-10-19 19:26:40 +02:00
819eb4fc56 feat: Add helpful hint text for server endpoint field
- Clarify HTTP/HTTPS support
- Note HTTP restriction to local networks
- Mention optional port configuration
- Indicate trailing slash not required
2025-10-19 19:17:30 +02:00
6385d10317 fix: Set gray tint color for server endpoint TextField placeholder 2025-10-19 19:16:12 +02:00
31ed3fc0e1 fix: Use @State instead of @StateObject for @Observable AppViewModel
- Replace @StateObject with @State for @Observable conformance
- Remove unnecessary Task wrapper in init
- Call loadSetupStatus() synchronously since it's already @MainActor
2025-10-19 19:06:35 +02:00
04de2c20d4 refactor: Use @Observable and inject factory in AppViewModel
- Replace ObservableObject with @Observable macro
- Inject UseCaseFactory instead of individual use cases
- Use factory.makeCheckServerReachabilityUseCase() on demand
- Use factory.makeLogoutUseCase() for 401 handling
2025-10-19 19:01:54 +02:00
fde1140f24 refactor: Check server reachability on app resume instead of app start
- Move server check from init to onAppResume() in AppViewModel
- Add scenePhase observer in readeckApp
- Check only when app becomes active (.active phase)
- Respects 30s cache - won't call API if recently checked
2025-10-19 11:08:13 +02:00
e5334d456d refactor: Remove NWPathMonitor auto-sync, keep only on-demand server checks
- Delete NetworkConnectivity.swift with problematic NWPathMonitor
- Remove serverDidBecomeAvailable notification
- Remove unused startAutoSync from OfflineSyncManager
- Server check now only on app start via AppViewModel
2025-10-19 10:47:19 +02:00
1957995a9e refactor: Update NetworkConnectivity to use CheckServerReachabilityUseCase 2025-10-19 10:45:21 +02:00
bf3ee7a1d7 fix: Add MockCheckServerReachabilityUseCase implementation 2025-10-19 10:32:44 +02:00
ef8ebd6f00 refactor: Optimize server connectivity with Clean Architecture
- Replace ServerConnectivity with CheckServerReachabilityUseCase
- Add InfoApiClient for /api/info endpoint
- Implement ServerInfoRepository with 30s cache TTL and 5s rate limiting
- Update ShareBookmarkViewModel to use ShareExtensionServerCheck manager
- Add server reachability check in AppViewModel on app start
- Update OfflineSyncManager to use new UseCase
- Extend SimpleAPI with checkServerReachability for Share Extension
2025-10-19 09:43:47 +02:00
eddc8a35ff bumped build version 2025-10-14 14:22:41 +02:00
446be3424e docs: Improve release notes with user-friendly language and better formatting
Release notes improvements:
- Rewrote technical descriptions for better user understanding
- Replaced technical jargon with clear benefits
- Added blank lines after section headers for better readability
- Focused on what users gain instead of implementation details

View improvements:
- Use AttributedString with proper markdown parsing
- Enable text selection for copying content
- Better markdown rendering with .interpretedSyntax option

Content updates:
- AppStore link and introduction added
- User-focused feature descriptions
- Clear benefit-oriented language
- Acknowledgment for community contributions
2025-10-14 14:20:39 +02:00
b8e5766cb1 feat: Add release notes system with auto-popup on version updates
Implement comprehensive release notes feature:
- RELEASE_NOTES.md with version 1.0 and 1.1 content in English
- VersionManager to track app versions and detect updates
- ReleaseNotesView with native markdown rendering
- Auto-popup sheet on first launch after version update
- Manual access via "What's New" button in General Settings

Features:
- Markdown-based release notes stored in app bundle
- Automatic version detection using CFBundleShortVersionString
- UserDefaults tracking of last seen version
- Dismissable sheet with "Done" button
- Settings button shows current version number

Technical implementation:
- VersionManager singleton for version tracking
- Sheet presentation in MainTabView on new version
- Settings integration with sparkles icon
- Native SwiftUI Text markdown rendering
- Bundle resource loading for RELEASE_NOTES.md

Release notes content:
- Version 1.1: iOS 26 features, floating buttons, progress tracking
- Version 1.0: Initial release features and capabilities
2025-10-14 14:04:28 +02:00
e61dbc7d72 feat: Add iOS 26 native WebView with floating action buttons and improved header
BookmarkDetailView2 enhancements:
- Implement floating action buttons with iOS 26 GlassEffect
- Buttons appear at 90% reading progress with slide-up animation
- Use GlassEffectContainer with liquid glass interaction effect
- Position buttons in bottom-right corner with spring animation
- Auto-hide when scrolling back above 90%

Header image improvements:
- Use aspect fit with blurred background for better image display
- Prevents random cropping of header images
- Maintains full image visibility while filling header space

Debug-only features:
- Add #if DEBUG wrapper for view toggle buttons
- Toggle between legacy and native WebView only in debug builds

Technical details:
- GlassEffectContainer with 52pt buttons and 31pt icons
- Spring animation (response: 0.6, damping: 0.8)
- Combined move and opacity transitions
- Full screen ScrollView with bottom safe area extension
- Blurred background layer for non-filling images
2025-10-14 13:53:31 +02:00
f302f8800f bumped build version 2025-10-12 22:17:42 +02:00
3d4c695ffa added some if debug checks 2025-10-12 22:17:03 +02:00
a5d94d1aee feat: Implement performant scroll progress tracking with PreferenceKey
Replace onScrollGeometryChange/onScrollPhaseChange with ContentHeightPreferenceKey
approach for improved scroll performance and accurate read progress tracking.

Changes:
- Add ScrollOffsetPreferenceKey and ContentHeightPreferenceKey for scroll tracking
- Track content end position dynamically as WebView loads
- Calculate progress from scroll offset relative to total scrollable content
- Implement 3% threshold for progress updates to reduce API calls
- Add progress locking at 100% to prevent pixel-variation regressions
- Guarantee 100% update when reaching end of content
- Apply to both BookmarkDetailLegacyView and BookmarkDetailView2

Technical approach:
- Place invisible marker at end of content to measure position in scrollView
- Update initialContentEndPosition as content grows during WebView loading
- Progress = (initialPosition - currentPosition) / (initialPosition - containerHeight)
- Lock progress once 100% reached to avoid 100% -> 99% fluctuations
2025-10-12 20:56:59 +02:00
bef6a9dc2f fix: Use total content height for read progress calculation
Added ContentHeightPreferenceKey to track the total ScrollView content height.

The bug: Progress was calculated using only webViewHeight - containerHeight,
which ignores the header, title, and other content above the webview.

The fix: Use total content height (header + title + webview + archive section)
instead of just webViewHeight for accurate progress calculation.

Changes:
- Added ContentHeightPreferenceKey preference key
- Added contentHeight state variable
- Added background GeometryReader to VStack to measure total content height
- Changed progress calculation: contentHeight - containerHeight (not webViewHeight)

Applied to both BookmarkDetailLegacyView and BookmarkDetailView2.
2025-10-10 20:27:17 +02:00
4595a9b69f fix: Add explicit width constraint to headerView in BookmarkDetailView2
Changed headerView from var to func with width parameter, matching LegacyView.
Added .frame(width: width, height: headerHeight) to constrain header image width.

This was the root cause of content overflow - without explicit width on the
header image, the entire ZStack and its children (including title and webview)
could grow beyond viewport width. Now matches LegacyView implementation exactly.
2025-10-10 20:23:38 +02:00
4c744e6d10 fix: Add comprehensive width constraints to NativeWebView CSS
Added multiple CSS rules to prevent content overflow:

Universal rules:
- * { max-width: 100%; box-sizing: border-box; }

HTML/Body:
- overflow-x: hidden on both html and body
- width: 100% to enforce viewport width
- word-wrap and overflow-wrap: break-word on body

Pre blocks:
- max-width: 100%
- white-space: pre-wrap (allows wrapping)
- word-wrap: break-word

Viewport meta:
- Added maximum-scale=1.0, user-scalable=no to prevent zooming issues

The native iOS 26+ WebView handles width differently than WKWebView,
requiring explicit overflow and width constraints in CSS.
2025-10-10 20:18:38 +02:00
615abf1d74 fix: Set explicit width constraint on VStack in BookmarkDetailView2
Added width: geometry.size.width to the spacer Color.clear.frame()
to constrain the VStack width, matching the LegacyView implementation.
This prevents NativeWebView content from overflowing the screen width.

The explicit width on the spacer propagates to the parent VStack,
which then constrains all child views including NativeWebView.
2025-10-10 20:08:32 +02:00
969f80c0a5 fix: Remove maxWidth infinity from NativeWebView in BookmarkDetailView2
Removed .frame(maxWidth: .infinity) from NativeWebView which was causing
the content to be wider than the viewport. The NativeWebView now respects
the parent container's width constraints set by .padding(.horizontal, 4).
2025-10-10 20:01:03 +02:00
842c404f04 fix: Add return statement to JavaScript height detection in NativeWebView
Added 'return' keyword before document.body.scrollHeight to ensure
the JavaScript expression returns a value that can be captured by
webPage.callJavaScript().
2025-10-10 19:56:35 +02:00
614042c3bd fix: Simplify NativeWebView CSS and JavaScript height detection
CSS Changes:
- Removed all overflow/max-width/word-break rules from body/html
- Simplified to match WebView.swift CSS structure exactly
- Only img keeps max-width: 100%
- Removed box-sizing and universal max-width rules

JavaScript Height Detection:
- Simplified from Math.max() with multiple properties to simple document.body.scrollHeight
- This matches how the standard WebView gets height
- Should resolve 'No valid JavaScript height found' errors

The width overflow was caused by aggressive CSS rules that interfered
with native layout. The height detection issue was likely due to complex
JavaScript expressions not working with webPage.callJavaScript().
2025-10-10 19:46:09 +02:00
008303d043 fix: Prevent content overflow in NativeWebView
- Added universal max-width: 100% to all elements
- Added overflow-wrap, word-wrap, and word-break to body
- Added overflow-x: hidden to html
- Fixed pre blocks with white-space: pre-wrap and max-width
- Fixed tables with display: block and overflow-x: auto
- Added word-wrap to table cells

This prevents wide content (long URLs, code blocks, tables) from
overflowing the viewport width in iOS 26+ NativeWebView.
2025-10-10 17:27:27 +02:00
37321f31c9 perf: Apply PreferenceKey optimization to BookmarkDetailView2 and fix spacing
- Implemented ScrollOffsetPreferenceKey for BookmarkDetailView2
- Replaced onScrollGeometryChange + onScrollPhaseChange with onPreferenceChange
- Removed currentScrollOffset and scrollViewHeight state variables
- Changed jumpButton from var to func with containerHeight parameter
- Fixed excessive spacing between header and content by using ZStack layout

Layout fix: Header image is now in ZStack background with content in foreground,
eliminating double spacing that occurred with separate VStacks.

Performance: Same PreferenceKey benefits as LegacyView - more efficient scroll tracking.
2025-10-10 17:24:17 +02:00
e9195351aa perf: Optimize scroll tracking with PreferenceKey instead of onScrollGeometryChange
- Implemented ScrollOffsetPreferenceKey for performance-optimized scroll tracking
- Added invisible GeometryReader at top of ScrollView to track offset
- Replaced onScrollGeometryChange + onScrollPhaseChange with onPreferenceChange
- Removed currentScrollOffset and scrollViewHeight state variables
- Continuous tracking with 3% threshold check in onPreferenceChange
- Updated JumpButton to receive containerHeight as parameter

PreferenceKey approach is more performant than onScrollGeometryChange:
- Single preference update instead of multiple geometry changes
- Direct access to scroll coordinate space
- Simpler state management with lastSentProgress threshold
2025-10-10 15:49:48 +02:00
a782a27eea revert: Remove JavaScript scroll tracking, back to SwiftUI-based solution
- Removed JavaScript scroll event listeners and console.log debugging
- Removed WebViewCoordinator.updateScrollProgress() method
- Removed onExternalScrollUpdate callback
- Removed webView reference and lastSentProgress from coordinator
- Restored scrollViewHeight state variable
- Restored JumpButton functionality with ScrollPosition
- Back to onScrollPhaseChange with 3% threshold for reading progress

The JavaScript approach didn't work because WebView scrolling is disabled
(embedded in SwiftUI ScrollView). The SwiftUI-based solution is cleaner
and performs well with onScrollPhaseChange.
2025-10-10 15:43:50 +02:00
5c9c00134a feat: Connect SwiftUI ScrollView tracking to WebView coordinator
- Added WebViewCoordinator reference storage in BookmarkDetailLegacyView
- Added updateScrollProgress() method to WebViewCoordinator with 3% threshold
- Connected onScrollPhaseChange to coordinator's updateScrollProgress
- Added onExternalScrollUpdate callback to pass coordinator reference
- Scroll progress now flows: SwiftUI ScrollView -> Coordinator -> onScroll callback

This bridges the gap between SwiftUI ScrollView (which wraps the WebView)
and the JavaScript-style scroll progress tracking with threshold.
2025-10-10 15:33:20 +02:00
0a53705df1 debug: Add comprehensive logging to JavaScript scroll tracking
- Added console.log statements in JavaScript for scroll events
- Added Swift print statements in message handler
- Added logging in BookmarkDetailLegacyView onScroll callback
- Logs cover: JS initialization, scroll events, message passing, Swift handling

This will help diagnose why scroll events aren't being captured.
2025-10-10 15:25:19 +02:00
32dbab400e feat: Implement JavaScript-based scroll tracking in BookmarkDetailLegacyView
- Added scroll progress tracking via JavaScript in WebView
- Implemented 3% threshold to reduce message frequency
- Removed SwiftUI onScrollGeometryChange and onScrollPhaseChange
- Cleaned up unused state variables (scrollViewHeight, currentScrollOffset)
- Removed Combine import (no longer needed)
- Disabled JumpButton scroll-to-position (requires JavaScript implementation)

This approach offloads scroll tracking to the WebView's JavaScript,
reducing SwiftUI state updates and improving performance.
2025-10-10 14:47:56 +02:00
171bf881fb feat: Add native SwiftUI WebView support with iOS 26+ BookmarkDetailView2
- Created BookmarkDetailView2 with native SwiftUI WebView (iOS 26+)
- Refactored BookmarkDetailView as version router
- Renamed original implementation to BookmarkDetailLegacyView
- Moved Archive/Favorite buttons to bottom toolbar using ToolbarItemGroup
- Added toggle button to switch between native and legacy views
- Implemented onScrollPhaseChange for optimized reading progress tracking
- Added NativeWebView component with improved JavaScript height detection
- All changes preserve existing functionality while adding modern alternatives
2025-10-10 00:27:59 +02:00
6addacb1d9 perf: Optimize ScrollView performance with onScrollGeometryChange
- Replace PreferenceKey-based scroll tracking with onScrollGeometryChange API
- Remove ScrollOffsetPreferenceKey struct (no longer needed)
- Add 50px threshold for scroll offset updates to reduce processing frequency
- Add 5% threshold for readingProgress state updates to minimize view refreshes
- Simplify header view by removing parallax effect and nested GeometryReader
- Keep single GeometryReader only for container dimensions (width/height)
- Fix WebView width constraints with explicit frame settings

This significantly improves scroll performance by reducing unnecessary
calculations and state updates during scrolling.
2025-10-09 19:22:49 +02:00
2834102d45 feat: Add iOS 26 search toolbar and tab bar minimize behaviors
- Add searchToolbarBehavior(.minimize) for iOS 26+ to improve search UX
- Add tabBarMinimizeBehavior(.onScrollDown) to auto-hide tab bar on scroll
- Remove redundant toolbar visibility modifiers from tab views
- Extract iOS 26+ compatibility helpers into reusable View extensions
- Bump version to 1.1 (build 26)
2025-10-07 22:08:29 +02:00
7b12bb4cf5 fix: Improve WebView performance by sanitizing problematic HTML attributes
- Remove jsaction, jscontroller, jsname attributes that trigger navigation events
- Strip unnecessary id attributes to reduce DOM size
- Remove tabindex from non-interactive elements
- Fix invalid nested p tags inside pre/span blocks
- Prevents WebKit crashes on complex HTML content
2025-10-07 20:41:43 +02:00
a2c805b700 missing file 2025-10-04 00:42:41 +02:00
ad7ac19d79 refactor: Clean up PhoneTabView and improve code organization
- Remove unused state: selectedMoreTab, searchPath
- Remove obsolete navigation callbacks (.onAppear, .onDisappear)
- Hide disclosure indicators in search results using ZStack pattern
- Add computed properties for cardLayoutStyle and badge count
- Mark .search case as EmptyView (now directly implemented)
- Hide tab bar in more menu detail views
2025-10-04 00:36:39 +02:00
080c5aa4d2 feat: Modernize PhoneTabView with iOS 18/26 adaptive search
Implement version-specific search UI:
- iOS 26+: Dedicated search Tab with .searchable() and role .search
- iOS 18-25: Classic search bar integrated in More tab
- Each main tab now has independent NavigationStack with separate path
- Conditional view switches between menu and search results
- Remove .search from moreTabs array (now integrated)
- Direct binding to SearchBookmarksViewModel.searchQuery
2025-10-04 00:13:19 +02:00
f3d52b3c3a feat: Implement correct iOS 18 Tab API syntax
- Use Tab(title, systemImage:) without value parameter as per iOS 18 standards
- Remove manual selection handling as TabView handles it automatically
- Simplify ForEach to iterate directly over tabs instead of enumeration
- Remove .tag() and .tabItem modifiers which are no longer needed
- Clean up selection state management for modern Tab API
2025-10-01 21:58:19 +02:00
a651398dca fix: Revert to working tabItem syntax due to compiler error
- Revert Tab() syntax that caused compiler diagnostic error
- Use proven .tabItem approach that works reliably
- Keep modern Label() components for better accessibility
- Maintain all functionality while ensuring compilation success
2025-10-01 21:56:50 +02:00
58b89d4c86 refactor: Remove legacy tabItem code and use only modern Tab API
- Remove iOS version checks and legacy .tabItem implementations
- Use modern Tab() syntax throughout as app targets iOS 18+ minimum
- Simplify code by removing duplicate implementations
- Remove @available annotations as they're no longer needed
- Clean up code structure while maintaining all functionality
2025-10-01 21:56:11 +02:00
62f2f07f38 feat: Modernize PhoneTabView with iOS 18+ Tab API
- Add support for new SwiftUI Tab API (iOS 18+) alongside legacy tabItem
- Implement mainTabsContentNew and moreTabContentNew with modern Tab() syntax
- Maintain backward compatibility with iOS versions < 18
- Use @available annotations for version-specific implementations
- Replace deprecated .tabItem with cleaner Tab(..., value:) approach
- Keep all existing functionality including badges and navigation
2025-10-01 21:55:15 +02:00
99ef722e7d perf: Add simple caching to KeychainTokenProvider
- Cache token and endpoint in memory to avoid repeated keychain access
- First call reads from keychain, subsequent calls use cached values
- Significantly improves performance for frequent API calls
- Simple implementation without unnecessary locking or complexity

fix: Properly URL-encode labels parameter for API requests

- Add quotes around label values to match API requirements
- Fix label filtering for labels with spaces (e.g. 'aa aa')
- Ensure proper URL encoding as required by server
- Maintains existing pagination and filtering functionality
2025-10-01 21:51:34 +02:00
3ea4e49686 fix: Properly URL-encode labels parameter for API requests
- Add quotes around label values to match API requirements
- Fix label filtering for labels with spaces (e.g. 'aa aa')
- Ensure proper URL encoding as required by server
- Maintains existing pagination and filtering functionality
2025-10-01 21:36:59 +02:00
f42d138f58 refactor: Clean up WebView code and remove debug prints
- Remove all debug print statements for cleaner output
- Group related properties in WebViewCoordinator for better organization
- Remove redundant comments throughout the code
- Simplify JavaScript code by removing unnecessary comments
- Maintain all functionality while improving code readability
2025-09-30 23:21:46 +02:00
f50ad505ae fix: Add memory leak prevention and proper WebView cleanup
- Add dismantleUIView method to properly cleanup WebView resources
- Remove script message handlers to prevent memory leaks
- Add cleanup() method to WebViewCoordinator with timer invalidation
- Clear all callbacks and references when view is destroyed
- Add isCleanedUp guard to prevent double cleanup
- Improve memory management for better stability
2025-09-30 23:20:00 +02:00
4c180c6a81 updated readme 2025-09-27 22:49:35 +02:00
8739716348 updated readme 2025-09-27 22:47:56 +02:00
c8c93b76da update README with new iPhone and iPad screenshots 2025-09-27 22:44:12 +02:00
3abeb3f3e4 new screenshots for the readme 2025-09-27 22:04:11 +02:00
f3147a6cc6 Merge branch 'develop' of https://codeberg.org/readeck/readeck-ios into develop 2025-09-26 21:58:54 +02:00
ac7f4e66eb fix: Improve Core Data thread safety and resolve scrolling flicker
- Add background context support to CoreDataManager
- Fix TagEntity threading crashes in LabelsRepository
- Prevent WebView height updates during scrolling to reduce flicker
- Add App Store download link to README
2025-09-26 21:56:49 +02:00
Ilyas Hallak
413d3843aa Merge pull request 'General Settings: Select if readeck opens external links via in app or default browser' (#7) from christian-putzke/readeck-ios:feature/url_opener into develop
Reviewed-on: https://codeberg.org/readeck/readeck-ios/pulls/7
2025-09-26 21:55:47 +02:00
Christian Putzke
b929611430 Code review fixes 2025-09-26 20:45:38 +02:00
Christian Putzke
d369791f27 Merge branch 'develop' into feature/url_opener 2025-09-22 06:03:18 +02:00
2791b7f227 bumped build version 2025-09-20 22:21:16 +02:00
52bf16a8eb fix: Update Privacy Policy date from placeholder to current date 2025-09-20 22:18:15 +02:00
051b5b169d fix: Update contact details in legal views 2025-09-20 22:15:32 +02:00
d6ea56cfa9 feat: Add comprehensive i18n support and Legal & Privacy section
- Create String+Localization extension with .localized property
- Add LabelUtils for consistent label splitting and deduplication logic
- Implement Legal & Privacy settings section with Privacy Policy and Legal Notice views
- Add German/English localization for all navigation states and settings sections
- Fix navigationDestination placement warning in PadSidebarView
- Unify label input handling across main app and share extension
- Support for space-separated label input in share extension

Navigation & Settings now fully localized:
- All/Unread/Favorites/Archive → Alle/Ungelesen/Favoriten/Archiv
- Font/Appearance/Cache/General/Server Settings → German equivalents
- Legal section with GitHub issue reporting and email support contact
2025-09-20 22:14:17 +02:00
Christian Putzke
f78de1f740 Added setting to select in app or default browser to open external links 2025-09-18 22:35:43 +02:00
Christian Putzke
26990c59fa Ignore .DS_Store files 2025-09-18 22:16:42 +02:00
534ceddad4 bumped build version 2025-09-17 22:39:42 +02:00
dcbe0515fc fix: Share extension title extraction and theme persistence
- Enable text support in share extension to extract page titles
- Extract titles from attributedTitle and attributedContentText
- Prevent titles from being used as URLs with proper validation
- Fix theme settings persistence using SettingsRepository instead of UserDefaults
- Theme changes now properly notify the app for immediate updates
2025-09-17 22:27:52 +02:00
ba74430d10 feat: Improve label input functionality
- Split label input on space to create multiple labels at once
- Disable autocapitalization in tag search field
- Prevent duplicate labels when adding multiple at once
2025-09-17 13:36:36 +02:00
fbf840888a bumped build version 2025-09-05 21:59:18 +02:00
c13fc107b1 fix: Card width consistency and layout loading in search
- Fixed natural layout width using screen bounds instead of infinity
- Added card layout settings loading in SearchBookmarksView
- Consistent card width across all views prevents overflow
2025-09-05 21:58:24 +02:00
f40c5597f3 version bump 2025-09-04 21:36:49 +02:00
5947312339 fix: Core Data threading and network error handling
- Add thread-safe NSManagedObjectContext extension
- Fix EXC_BAD_ACCESS with performAndWait wrappers
- Add network error detection with retry functionality
- Change hero image to aspectFill for better layout
- Mark classes as @unchecked Sendable for Swift Concurrency
2025-09-04 21:15:54 +02:00
5b520995ac typo 2025-09-04 12:14:44 +02:00
8fb2a2a14e fix: Add circular progress for delete countdown 2025-09-04 12:14:20 +02:00
df8a7b64b2 feat: Add Kingfisher caching, card layouts, dynamic tag layout, and undo delete
- Integrate Kingfisher for image caching with CachedAsyncImage component
- Add CacheSettingsView for managing image cache size and clearing cache
- Implement three card layout styles: compact, magazine (default), natural
- Add AppearanceSettingsView with visual layout previews and theme settings
- Create Clean Architecture for card layout with domain models and use cases
- Implement FlowLayout for dynamic label width calculation
- Add skeleton loading animation for initial bookmark loads
- Replace delete confirmation dialogs with immediate delete + 3-second undo
- Support multiple simultaneous undo operations with individual progress bars
- Add grayed-out visual feedback for pending deletions
- Centralize notification names in dedicated NotificationNames file
- Remove pagination logic from label management (replaced with FlowLayout)
- Update AsyncImage usage across BookmarkCardView, BookmarkDetailView, ImageViewerView
- Improve UI consistency and spacing throughout the app
2025-09-04 10:43:27 +02:00
680a9562be updated gitignore 2025-08-29 21:07:54 +02:00
2f55da92c0 feat: Convert localization from xcstrings to traditional .strings format
- Migrate from Localizable.xcstrings to .lproj structure for Weblate compatibility
- Create Base, English, and German localization directories
- Update Xcode project configuration to use .strings files
- Clean up unused xcstrings references from project file
2025-08-28 18:50:11 +02:00
953ff5da8d feat: Implement persistent logout on 401 errors and hide TabBar in detail views
- Add AppViewModel to manage app-level state and handle 401 responses
- Implement automatic logout when API returns 401 Unauthorized
- Add persistent logout state using existing hasFinishedSetup flag
- Move NavigationStack outside TabView to enable automatic TabBar hiding
- Update API classes to send UnauthorizedAPIResponse notifications
- TabBar now hides automatically when navigating to detail views
2025-08-27 22:04:37 +02:00
137 changed files with 10263 additions and 2050 deletions

6
.gitignore vendored
View File

@ -63,3 +63,9 @@ fastlane/screenshots/**/*.png
fastlane/test_output
fastlane/.env.default
fastlane/AuthKey_JZJCQWW9N3.p8
# Documentation
documentation/
# macOS
**/.DS_Store

View File

@ -1,27 +0,0 @@
# Changelog
All changes to this project will be documented in this file.
## Planned for Version 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
- Save bookmarks when server is unavailable and sync when reconnected
## Planned for Version 1.1.0
- [ ] Add support for bookmark filtering and sorting options
- [ ] Add support for collection management
- [ ] Add support for custom themes
- [ ] Text highlighting of selected text in a article
- [ ] Multiple selection of bookmarks for bulk actions

View File

@ -7,33 +7,49 @@ A native iOS client for [readeck](https://readeck.org) bookmark management.
The official repository is on Codeberg:
https://codeberg.org/readeck/readeck
## Screenshots
## Download
<p align="center">
<img src="screenshots/main.webp" height="400" alt="Main View">
<img src="screenshots/detail.webp" height="400" alt="Detail View">
<img src="screenshots/new.webp" height="400" alt="Add Bookmark">
<img src="screenshots/more.webp" height="400" alt="More Options">
<img src="screenshots/share.webp" height="400" alt="Share Extension">
<img src="screenshots/ipad.webp" height="400" alt="iPad View">
</p>
## TestFlight Beta Access
You can now join the public TestFlight beta for the Readeck iOS app:
### App Store (Stable Releases)
<a href="https://apps.apple.com/de/app/readeck/id6748764703">
<img src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" alt="Download on the App Store" width="200">
</a>
### TestFlight Beta Access (Early Releases)
[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.
For early access to new features and beta versions (use with caution). 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).
- For details and recent changes, please refer to the release notes in TestFlight or the [Release Notes](./readeck/UI/Resources/RELEASE_NOTES.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.
## Screenshots
### iPhone
<p align="center">
<img src="screenshots/iphone_1.png" height="400" alt="iPhone Screenshot 1">
<img src="screenshots/iphone_2.png" height="400" alt="iPhone Screenshot 2">
<img src="screenshots/iphone_3.png" height="400" alt="iPhone Screenshot 3">
<img src="screenshots/iphone_4.png" height="400" alt="iPhone Screenshot 4">
<img src="screenshots/iphone_5.png" height="400" alt="iPhone Screenshot 5">
</p>
### iPad
<p align="center">
<img src="screenshots/ipad_1.jpg" height="400" alt="iPad Screenshot 1">
<img src="screenshots/ipad_2.jpg" height="400" alt="iPad Screenshot 2">
<img src="screenshots/ipad_3.jpg" height="400" alt="iPad Screenshot 3">
<img src="screenshots/ipad_4.jpg" height="400" alt="iPad Screenshot 4">
<img src="screenshots/ipad_5.jpg" height="400" alt="iPad Screenshot 5">
</p>
## Core Features
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
@ -68,7 +84,7 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
## Versions
[see Changelog](./CHANGELOG.md)
[see Release Notes](./readeck/UI/Resources/RELEASE_NOTES.md)
## Contributing

View File

@ -8,6 +8,8 @@
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>

View File

@ -1,7 +1,7 @@
import Foundation
import CoreData
class OfflineBookmarkManager {
class OfflineBookmarkManager: @unchecked Sendable {
static let shared = OfflineBookmarkManager()
private init() {}
@ -17,27 +17,31 @@ class OfflineBookmarkManager {
func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool {
let tagsString = tags.joined(separator: ",")
// Check if URL already exists offline
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "url == %@", url)
do {
let existingEntities = try context.fetch(fetchRequest)
if let existingEntity = existingEntities.first {
// Update existing entry
existingEntity.tags = tagsString
existingEntity.title = title
} else {
// Create new entry
let entity = ArticleURLEntity(context: context)
entity.id = UUID()
entity.url = url
entity.title = title
entity.tags = tagsString
try context.safePerform { [weak self] in
guard let self = self else { return }
// Check if URL already exists offline
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "url == %@", url)
let existingEntities = try self.context.fetch(fetchRequest)
if let existingEntity = existingEntities.first {
// Update existing entry
existingEntity.tags = tagsString
existingEntity.title = title
} else {
// Create new entry
let entity = ArticleURLEntity(context: self.context)
entity.id = UUID()
entity.url = url
entity.title = title
entity.tags = tagsString
}
try self.context.save()
print("Bookmark saved offline: \(url)")
}
try context.save()
print("Bookmark saved offline: \(url)")
return true
} catch {
print("Failed to save offline bookmark: \(error)")
@ -45,16 +49,102 @@ class OfflineBookmarkManager {
}
}
func getTags() -> [String] {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
func getTags() async -> [String] {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
let tagEntities = try context.fetch(fetchRequest)
return tagEntities.compactMap { $0.name }.sorted()
return try await backgroundContext.perform {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
let tagEntities = try backgroundContext.fetch(fetchRequest)
return tagEntities.compactMap { $0.name }
}
} catch {
print("Failed to fetch tags: \(error)")
return []
}
}
func saveTags(_ tags: [String]) async {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
try await backgroundContext.perform {
// Batch fetch existing tags
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
let existingNames = Set(existingEntities.compactMap { $0.name })
// Only insert new tags
var insertCount = 0
for tag in tags {
if !existingNames.contains(tag) {
let entity = TagEntity(context: backgroundContext)
entity.name = tag
entity.count = 0
insertCount += 1
}
}
// Only save if there are new tags
if insertCount > 0 {
try backgroundContext.save()
print("Saved \(insertCount) new tags to Core Data")
}
}
} catch {
print("Failed to save tags: \(error)")
}
}
func saveTagsWithCount(_ tags: [BookmarkLabelDto]) async {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
try await backgroundContext.perform {
// Batch fetch existing tags
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
var existingByName: [String: TagEntity] = [:]
for entity in existingEntities {
if let name = entity.name {
existingByName[name] = entity
}
}
// Insert or update tags
var insertCount = 0
var updateCount = 0
for tag in tags {
if let existing = existingByName[tag.name] {
// Update count if changed
if existing.count != tag.count {
existing.count = Int32(tag.count)
updateCount += 1
}
} else {
// Insert new tag
let entity = TagEntity(context: backgroundContext)
entity.name = tag.name
entity.count = Int32(tag.count)
insertCount += 1
}
}
// Only save if there are changes
if insertCount > 0 || updateCount > 0 {
try backgroundContext.save()
print("Saved \(insertCount) new tags and updated \(updateCount) tags to Core Data")
}
}
} catch {
print("Failed to save tags with count: \(error)")
}
}
}

View File

@ -1,62 +0,0 @@
import Foundation
import Network
class ServerConnectivity: ObservableObject {
@Published var isServerReachable = false
static let shared = ServerConnectivity()
private init() {}
// Check if the Readeck server endpoint is reachable
static func isServerReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint + "/api/health") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 5.0 // 5 second timeout
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
print("Server connectivity check failed: \(error)")
}
return false
}
// Alternative check using ping-style endpoint
static func isServerReachableSync() -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint) else {
return false
}
let semaphore = DispatchSemaphore(value: 0)
var isReachable = false
var request = URLRequest(url: url)
request.httpMethod = "HEAD" // Just check if server responds
request.timeoutInterval = 3.0
let task = URLSession.shared.dataTask(with: request) { _, response, error in
if let httpResponse = response as? HTTPURLResponse {
isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error
}
semaphore.signal()
}
task.resume()
_ = semaphore.wait(timeout: .now() + 3.0)
return isReachable
}
}

View File

@ -1,12 +1,15 @@
import SwiftUI
import CoreData
struct ShareBookmarkView: View {
@ObservedObject var viewModel: ShareBookmarkViewModel
@State private var keyboardHeight: CGFloat = 0
@FocusState private var focusedField: AddBookmarkFieldFocus?
@Environment(\.managedObjectContext) private var viewContext
private func dismissKeyboard() {
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
}
var body: some View {
@ -39,7 +42,6 @@ struct ShareBookmarkView: View {
saveButtonSection
}
.background(Color(.systemGroupedBackground))
.onAppear { viewModel.onAppear() }
.ignoresSafeArea(.keyboard, edges: .bottom)
.contentShape(Rectangle())
.onTapGesture {
@ -134,33 +136,31 @@ struct ShareBookmarkView: View {
@ViewBuilder
private var tagManagementSection: some View {
if !viewModel.labels.isEmpty || !viewModel.isServerReachable {
TagManagementView(
allLabels: convertToBookmarkLabels(viewModel.labels),
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: false,
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
searchFieldFocus: $focusedField,
onAddCustomTag: {
addCustomTag()
},
onToggleLabel: { label in
if viewModel.selectedLabels.contains(label) {
viewModel.selectedLabels.remove(label)
} else {
viewModel.selectedLabels.insert(label)
}
viewModel.searchText = ""
},
onRemoveLabel: { label in
CoreDataTagManagementView(
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
searchFieldFocus: $focusedField,
fetchLimit: 150,
sortOrder: viewModel.tagSortOrder,
availableLabelsTitle: "Most used labels",
context: viewContext,
onAddCustomTag: {
addCustomTag()
},
onToggleLabel: { label in
if viewModel.selectedLabels.contains(label) {
viewModel.selectedLabels.remove(label)
} else {
viewModel.selectedLabels.insert(label)
}
)
.padding(.top, 20)
.padding(.horizontal, 16)
}
viewModel.searchText = ""
},
onRemoveLabel: { label in
viewModel.selectedLabels.remove(label)
}
)
.padding(.top, 20)
.padding(.horizontal, 16)
}
@ViewBuilder
@ -199,29 +199,8 @@ struct ShareBookmarkView: View {
}
// 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 = ""
}
viewModel.addCustomTag(context: viewContext)
}
}

View File

@ -6,70 +6,46 @@ import CoreData
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 = ""
@Published var isServerReachable: Bool = true
let tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount
let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel
// 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 = 12 // Extension can't access 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)])
}
}
}
private let serverCheck = ShareExtensionServerCheck.shared
private let tagRepository = TagRepository()
init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
extractSharedContent()
}
func onAppear() {
logger.debug("ShareBookmarkViewModel appeared")
checkServerReachability()
loadLabels()
}
private func checkServerReachability() {
let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger)
isServerReachable = ServerConnectivity.isServerReachableSync()
logger.info("Server reachability checked: \(isServerReachable)")
measurement.end()
}
private func extractSharedContent() {
logger.debug("Starting to extract shared content")
guard let extensionContext = extensionContext else {
logger.warning("No extension context available for content extraction")
return
}
var extractedUrl: String?
var extractedTitle: String?
for item in extensionContext.inputItems {
guard let inputItem = item as? NSExtensionItem else { continue }
// Use the inputItem's attributedTitle or attributedContentText as potential title
if let attributedTitle = inputItem.attributedTitle?.string, !attributedTitle.isEmpty {
extractedTitle = attributedTitle
logger.info("Extracted title from input item: \(attributedTitle)")
} else if let attributedContent = inputItem.attributedContentText?.string, !attributedContent.isEmpty {
extractedTitle = attributedContent
logger.info("Extracted title from content text: \(attributedContent)")
}
for attachment in inputItem.attachments ?? [] {
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
@ -77,6 +53,12 @@ class ShareBookmarkViewModel: ObservableObject {
if let url = url as? URL {
self?.url = url.absoluteString
self?.logger.info("Extracted URL from shared content: \(url.absoluteString)")
// Set title if we extracted one and current title is empty
if let title = extractedTitle, self?.title.isEmpty == true {
self?.title = title
self?.logger.info("Set title from shared content: \(title)")
}
} else if let error = error {
self?.logger.error("Failed to extract URL: \(error.localizedDescription)")
}
@ -86,9 +68,18 @@ class ShareBookmarkViewModel: ObservableObject {
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
self?.logger.info("Extracted URL from shared text: \(url.absoluteString)")
if let text = text as? String {
// Only treat as URL if it's a valid URL and we don't have one yet
if self?.url == nil, let url = URL(string: text), url.scheme != nil {
self?.url = url.absoluteString
self?.logger.info("Extracted URL from shared text: \(url.absoluteString)")
} else {
// If not a valid URL or we already have a URL, treat as potential title
if self?.title.isEmpty == true {
self?.title = text
self?.logger.info("Set title from shared text: \(text)")
}
}
} else if let error = error {
self?.logger.error("Failed to extract text: \(error.localizedDescription)")
}
@ -98,39 +89,7 @@ class ShareBookmarkViewModel: ObservableObject {
}
}
}
func loadLabels() {
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
logger.debug("Starting to load labels")
Task {
let serverReachable = ServerConnectivity.isServerReachableSync()
logger.debug("Server reachable for labels: \(serverReachable)")
if serverReachable {
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)
self.logger.info("Loaded \(loaded.count) labels from API")
measurement.end()
}
} else {
let localTags = OfflineBookmarkManager.shared.getTags()
let localLabels = localTags.enumerated().map { index, tagName in
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
await MainActor.run {
self.labels = localLabels
self.logger.info("Loaded \(localLabels.count) labels from local database")
measurement.end()
}
}
}
}
func save() {
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
guard let url = url, !url.isEmpty else {
@ -140,14 +99,14 @@ class ShareBookmarkViewModel: ObservableObject {
}
isSaving = true
logger.debug("Set saving state to true")
// Check server connectivity
let serverReachable = ServerConnectivity.isServerReachableSync()
logger.debug("Server connectivity for save: \(serverReachable)")
if serverReachable {
// Online - try to save via API
logger.info("Attempting to save bookmark via API")
Task {
Task {
let serverReachable = await serverCheck.checkServerReachability()
logger.debug("Server connectivity for save: \(serverReachable)")
if serverReachable {
// Online - try to save via API
logger.info("Attempting to save bookmark via API")
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
self?.logger.info("API save completed - Success: \(!error), Message: \(message)")
self?.statusMessage = (message, error, error ? "" : "")
@ -161,40 +120,67 @@ class ShareBookmarkViewModel: ObservableObject {
self?.logger.error("Failed to save bookmark via API: \(message)")
}
}
}
} else {
// Server not reachable - save locally
logger.info("Server not reachable, attempting local save")
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
url: url,
title: title,
tags: Array(selectedLabels)
)
logger.info("Local save result: \(success)")
DispatchQueue.main.async {
self.isSaving = false
} else {
// Server not reachable - save locally
logger.info("Server not reachable, attempting local save")
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
url: url,
title: title,
tags: Array(selectedLabels)
)
logger.info("Local save result: \(success)")
await MainActor.run {
self.isSaving = false
if success {
self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
} else {
self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "")
}
}
if success {
self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await MainActor.run {
self.completeExtensionRequest()
}
} else {
self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "")
}
}
}
}
func addCustomTag(context: NSManagedObjectContext) {
let splitLabels = LabelUtils.splitLabelsFromInput(searchText)
// Fetch available labels from Core Data
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
let availableLabels = (try? context.fetch(fetchRequest))?.compactMap { $0.name } ?? []
let currentLabels = Array(selectedLabels)
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
for label in uniqueLabels {
selectedLabels.insert(label)
// Save new label to Core Data so it's available next time
tagRepository.saveNewLabel(name: label, context: context)
}
// Force refresh of @FetchRequest in CoreDataTagManagementView
// This ensures newly created labels appear immediately in the search results
context.refreshAllObjects()
searchText = ""
}
private func completeExtensionRequest() {
logger.debug("Completing extension request")
guard let context = extensionContext else {
logger.warning("Extension context not available for completion")
return
}
context.completeRequest(returningItems: []) { [weak self] error in
if error {
self?.logger.error("Extension completion failed: \(error)")

View File

@ -0,0 +1,41 @@
import Foundation
/// Simple server check manager for Share Extension with caching
class ShareExtensionServerCheck {
static let shared = ShareExtensionServerCheck()
// Cache properties
private var cachedResult: Bool?
private var lastCheckTime: Date?
private let cacheTTL: TimeInterval = 30.0
private init() {}
func checkServerReachability() async -> Bool {
// Check cache first
if let cached = getCachedResult() {
return cached
}
// Use SimpleAPI for actual check
let result = await SimpleAPI.checkServerReachability()
updateCache(result: result)
return result
}
// MARK: - Cache Management
private func getCachedResult() -> Bool? {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedResult else {
return nil
}
return cached
}
private func updateCache(result: Bool) {
cachedResult = result
lastCheckTime = Date()
}
}

View File

@ -11,14 +11,15 @@ import UniformTypeIdentifiers
import SwiftUI
class ShareViewController: UIViewController {
private var hostingController: UIHostingController<ShareBookmarkView>?
private var hostingController: UIHostingController<AnyView>?
override func viewDidLoad() {
super.viewDidLoad()
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext)
let swiftUIView = ShareBookmarkView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: swiftUIView)
.environment(\.managedObjectContext, CoreDataManager.shared.context)
let hostingController = UIHostingController(rootView: AnyView(swiftUIView))
addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view)
@ -34,7 +35,7 @@ class ShareViewController: UIViewController {
NotificationCenter.default.addObserver(
self,
selector: #selector(dismissKeyboard),
name: NSNotification.Name("DismissKeyboard"),
name: .dismissKeyboard,
object: nil
)
}

View File

@ -2,7 +2,40 @@ import Foundation
class SimpleAPI {
private static let logger = Logger.network
// MARK: - Server Info
static func checkServerReachability() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: "\(endpoint)/api/info") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "accept")
request.timeoutInterval = 5.0
if let token = KeychainHelper.shared.loadToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
}
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode {
logger.info("Server is reachable")
return true
}
} catch {
logger.error("Server reachability check failed: \(error.localizedDescription)")
return false
}
return false
}
// MARK: - API Methods
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
logger.info("Adding bookmark: \(url)")
@ -39,6 +72,11 @@ class SimpleAPI {
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
guard 200...299 ~= httpResponse.statusCode else {
if httpResponse.statusCode == 401 {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
}
}
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
logger.error("Server error \(httpResponse.statusCode): \(msg)")
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
@ -87,6 +125,11 @@ class SimpleAPI {
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
guard 200...299 ~= httpResponse.statusCode else {
if httpResponse.statusCode == 401 {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
}
}
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
logger.error("Server error \(httpResponse.statusCode): \(msg)")
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)

View File

@ -1,5 +1,17 @@
import Foundation
public struct ServerInfoDto: Codable {
public let version: String
public let buildDate: String?
public let userAgent: String?
public enum CodingKeys: String, CodingKey {
case version
case buildDate = "build_date"
case userAgent = "user_agent"
}
}
public struct CreateBookmarkRequestDto: Codable {
public let labels: [String]?
public let title: String?
@ -33,4 +45,3 @@ public struct BookmarkLabelDto: Codable, Identifiable {
self.href = href
}
}

View File

@ -0,0 +1,63 @@
import Foundation
import CoreData
/// Simple repository for managing tags in Share Extension
class TagRepository {
private let logger = Logger.data
/// Saves a new label to Core Data if it doesn't already exist
/// - Parameters:
/// - name: The label name to save
/// - context: The managed object context to use
func saveNewLabel(name: String, context: NSManagedObjectContext) {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// Perform save in a synchronous block to ensure it completes before extension closes
context.performAndWait {
// Check if label already exists
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
fetchRequest.fetchLimit = 1
do {
let existingTags = try context.fetch(fetchRequest)
// Only create if it doesn't exist
if existingTags.isEmpty {
let newTag = TagEntity(context: context)
newTag.name = trimmedName
newTag.count = 1 // New label is being used immediately
try context.save()
logger.info("Successfully saved new label '\(trimmedName)' to Core Data")
// Force immediate persistence to disk for share extension
// Based on: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
// 1. Process pending changes
context.processPendingChanges()
// 2. Ensure persistent store coordinator writes to disk
// This is critical for extensions as they may be terminated quickly
if context.persistentStoreCoordinator != nil {
// Refresh all objects to ensure changes are pushed to store
context.refreshAllObjects()
// Reset staleness interval temporarily to force immediate persistence
let originalStalenessInterval = context.stalenessInterval
context.stalenessInterval = 0
context.refreshAllObjects()
context.stalenessInterval = originalStalenessInterval
logger.debug("Forced context refresh to ensure persistence")
}
} else {
logger.debug("Label '\(trimmedName)' already exists, skipping creation")
}
} catch {
logger.error("Failed to save new label '\(trimmedName)': \(error.localizedDescription)")
}
}
}
}

41
docs/401.md Normal file
View File

@ -0,0 +1,41 @@
# Feature: Persistentes Logout bei 401 Unauthorized
## Problemstellung
Wenn eine API-Anfrage mit einem `401 Unauthorized`-Response fehlschlägt, bedeutet dies, dass der aktuell gespeicherte Token oder die Session des Nutzers ungültig ist (z. B. durch manuelles Löschen des Tokens im Backend oder andere Ursachen).
In diesem Zustand darf der User nicht weiter mit einer scheinbar gültigen Session in der App interagieren.
## Ziel
Die App soll den Nutzer in einem solchen Fall automatisch vollständig **ausloggen** und auf den **Setup-/Login-Screen** umleiten.
Dies muss **persistiert** sein, d.h. auch nach App-Neustart darf der Nutzer nicht eingeloggt zurückkehren, solange keine erfolgreiche neue Anmeldung durchgeführt wurde.
---
## Anforderungen
1. **Erkennen von ungültigem Token**
- Jede API-Antwort mit `401 Unauthorized` löst den Logout-Prozess aus.
- Optional: Kontext beachten (z. B. ob der Request ein Refresh-Token war).
2. **Logout-Mechanismus**
- Alle gespeicherten Zugangsdaten (Access Token, Refresh Token, User-Daten im Keychain/Storage) werden gelöscht.
- UI-State wird in den "nicht eingeloggten" Zustand zurückversetzt.
- Persistenter "loggedOut"-State wird gesetzt (z. B. in `UserDefaults` oder einer App-State-DB).
3. **Persistenz**
- Falls der Nutzer die App neu startet, startet er im Setup-/Login-Screen und nicht in einem alten Session-Kontext.
4. **Wiederanmeldung**
- Sobald der Nutzer sich neu einloggt und erfolgreich ein Access Token erhält:
- wird der persistente "loggedOut"-State zurückgesetzt
- die App verhält sich wieder wie gewohnt im eingeloggten Zustand.
---
## Beispiel-Use Case
- User ist eingeloggt in die App.
- Im Backend wird manuell der Token gelöscht oder die Session invalidiert.
- Nächster API-Call → API gibt `401 Unauthorized` zurück.
- App erkennt ungültigen Zustand, **löscht alle Tokens und Session-Daten** und leitet den User sofort auf den **Setup-/Login-Screen** um.
- Auch nach einem App-Neustart startet der User weiterhin im Setup-Screen.
- Sobald der User sich erfolgreich einloggt, gelten alle API-Calls wieder normal.

View File

@ -15,9 +15,9 @@
```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["UI Layer (View, ViewModel)"]
Domain["Domain Layer (Use Cases, Models, Repository Protocols)"]
Data["Data Layer (Repository implementations, Database, Entities, API)"]
UI --> Domain
Domain --> Data
```

283
docs/Tag-Sync-Review.md Normal file
View File

@ -0,0 +1,283 @@
# Code Review - Tag Management Refactoring
**Commit**: ec5706c - Refactor tag management to use Core Data with configurable sorting
**Date**: 2025-11-08
**Files Changed**: 31 files (+747, -264)
## Overview
This review covers a comprehensive refactoring of the tag management system, migrating from API-based tag loading to a Core Data-first approach with background synchronization.
---
## ✅ Strengths
### Architecture & Design
1. **Clean Architecture Compliance**
- New `SyncTagsUseCase` properly separates concerns
- ViewModels now only interact with UseCases, not Repositories
- Proper dependency injection through UseCaseFactory
2. **Performance Improvements**
- Cache-first strategy provides instant UI response
- Background sync eliminates UI blocking
- Reduced server load through local caching
- SwiftUI `@FetchRequest` provides automatic reactive updates
3. **Offline Support**
- Tags work completely offline using Core Data
- Share Extension uses cached tags (no network required)
- Graceful degradation when server is unreachable
4. **User Experience**
- Configurable sorting (by count/alphabetically)
- Clear sorting indicators in UI
- Proper localization (EN/DE)
- "Most used tags" in Share Extension for quick access
### Code Quality
1. **Consistency**
- Consistent use of `@MainActor` for UI updates
- Proper async/await patterns throughout
- Clear naming conventions
2. **Documentation**
- Comprehensive commit message
- Inline documentation for complex logic
- `Tags-Sync.md` documentation created
3. **Testing Support**
- Mock implementations added for all new UseCases
- Testable architecture with clear boundaries
---
## ⚠️ Issues & Concerns
### Critical
None identified.
### Major
1. **LabelsRepository Duplication** (Priority: HIGH)
- `LabelsRepository` is instantiated multiple times in different factories
- Not using lazy singleton pattern
- Could lead to multiple concurrent API calls
**Location**:
- `DefaultUseCaseFactory.makeGetLabelsUseCase()` - line 101
- `DefaultUseCaseFactory.makeSyncTagsUseCase()` - line 107
**Impact**: Inefficient, potential race conditions
2. **Missing Error Handling** (Priority: MEDIUM)
- `syncTags()` silently swallows all errors with `try?`
- No user feedback if sync fails
- No retry mechanism
**Locations**:
- `AddBookmarkViewModel.syncTags()` - line 69
- `BookmarkLabelsViewModel.syncTags()` - line 45
3. **Legacy Code Not Fully Removed** (Priority: LOW)
- `AddBookmarkViewModel.loadAllLabels()` still exists but unused
- `BookmarkLabelsViewModel.allLabels` property unused
- `LegacyTagManagementView` marked deprecated but not removed
**Impact**: Code bloat, confusion for future developers
### Minor
1. **Hardcoded Values**
- Share Extension: `fetchLimit: 150` hardcoded in view
- Should be a constant
**Location**: `ShareBookmarkView.swift:143`
2. **Inconsistent Localization Approach**
- Share Extension uses `"Most used tags"` directly in code
- Should use `.localized` extension like main app
**Location**: `ShareBookmarkView.swift:145`
3. **Missing Documentation**
- `CoreDataTagManagementView` has no class-level documentation
- Complex `@FetchRequest` initialization not explained
**Location**: `CoreDataTagManagementView.swift:4`
4. **Code Duplication**
- Tag sync logic duplicated in `GetLabelsUseCase` and `SyncTagsUseCase`
- Both just call `labelsRepository.getLabels()`
**Locations**:
- `GetLabelsUseCase.execute()` - line 14
- `SyncTagsUseCase.execute()` - line 19
---
## 🔍 Specific File Reviews
### ShareBookmarkViewModel.swift
**Status**: ✅ Good
**Changes**: Removed 92 lines of label fetching logic
- ✅ Properly simplified by removing API logic
- ✅ Uses Core Data via `addCustomTag()` helper
- ✅ Clean separation of concerns
- ⚠️ Could add logging for Core Data fetch failures
### CoreDataTagManagementView.swift
**Status**: ✅ Good
**Changes**: New file, 255 lines
- ✅ Well-structured with clear sections
- ✅ Proper use of `@FetchRequest`
- ✅ Flexible with optional parameters
- ⚠️ Needs class/struct documentation
- ⚠️ `availableTagsTitle` parameter could be better named (`customSectionTitle`?)
### SyncTagsUseCase.swift
**Status**: ⚠️ Needs Improvement
**Changes**: New file, 21 lines
- ✅ Follows UseCase pattern correctly
- ✅ Good documentation comment
- ⚠️ Essentially duplicates `GetLabelsUseCase`
- 💡 Could be merged or one could wrap the other
### LabelsRepository.swift
**Status**: ✅ Excellent
**Changes**: Enhanced with batch updates and conflict detection
- ✅ Excellent cache-first + background sync implementation
- ✅ Proper batch operations
- ✅ Silent failure handling
- ✅ Efficient Core Data updates (only saves if changed)
### AddBookmarkView.swift
**Status**: ✅ Good
**Changes**: Migrated to CoreDataTagManagementView
- ✅ Clean migration from old TagManagementView
- ✅ Proper use of AppSettings for sort order
- ✅ Clear UI with sort indicator
- ⚠️ `.onAppear` and `.task` mixing removed - good!
### Settings Integration
**Status**: ✅ Excellent
**Changes**: New TagSortOrder setting with persistence
- ✅ Clean domain model separation
- ✅ Proper persistence in SettingsRepository
- ✅ Good integration with AppSettings
- ✅ UI properly reflects settings changes
---
## 📋 TODO List - Improvements
### High Priority
- [ ] **Refactor LabelsRepository instantiation**
- Create lazy singleton in DefaultUseCaseFactory
- Reuse same instance for GetLabelsUseCase and SyncTagsUseCase
- Add comment explaining why singleton is safe here
- [ ] **Add error handling to sync operations**
- Log errors instead of silently swallowing
- Consider adding retry logic with exponential backoff
- Optional: Show subtle indicator when sync fails
- [ ] **Remove unused legacy code**
- Delete `AddBookmarkViewModel.loadAllLabels()`
- Delete `BookmarkLabelsViewModel.allLabels` property
- Remove `LegacyTagManagementView.swift` entirely (currently just deprecated)
### Medium Priority
- [ ] **Extract constants**
- Create `Constants.Tags.maxShareExtensionTags = 150`
- Create `Constants.Tags.fetchBatchSize = 20`
- Reference in CoreDataTagManagementView and ShareBookmarkView
- [ ] **Improve localization consistency**
- Use `.localized` extension in ShareBookmarkView
- Ensure all user-facing strings are localized
- [ ] **Add documentation**
- Document `CoreDataTagManagementView` with usage examples
- Explain `@FetchRequest` initialization pattern
- Add example of how to use `availableTagsTitle` parameter
### Low Priority
- [ ] **Consolidate UseCases**
- Consider if `SyncTagsUseCase` is necessary
- Option 1: Make `GetLabelsUseCase` have a `syncOnly` parameter
- Option 2: Have `SyncTagsUseCase` wrap `GetLabelsUseCase`
- Document decision either way
- [ ] **Add unit tests**
- Test `SyncTagsUseCase` with mock repository
- Test `CoreDataTagManagementView` sort order changes
- Test tag sync triggers in ViewModels
- [ ] **Performance monitoring**
- Add metrics for tag sync duration
- Track cache hit rate
- Monitor Core Data batch operation performance
- [ ] **Improve parameter naming**
- Rename `availableTagsTitle` to `customSectionTitle` or `sectionHeaderTitle`
- More descriptive than "available tags"
---
## 🎯 Summary
### Overall Assessment: ✅ **EXCELLENT**
This refactoring successfully achieves its goals:
- ✅ Improved performance through caching
- ✅ Better offline support
- ✅ Cleaner architecture
- ✅ Enhanced user experience
### Risk Level: **LOW**
The changes are well-structured and follow established patterns. The main risks are:
1. Repository instantiation inefficiency (easily fixed)
2. Silent error handling (minor, can be improved later)
### Recommendation: **APPROVE with minor follow-ups**
The code is production-ready. The identified improvements are optimizations and cleanups that can be addressed in follow-up commits without blocking deployment.
---
## 📊 Metrics
- **Lines Added**: 747
- **Lines Removed**: 264
- **Net Change**: +483 lines
- **Files Modified**: 31
- **New Files**: 7
- **Deleted Files**: 0 (1 renamed)
- **Test Coverage**: Mocks added ✅
---
## 🏆 Best Practices Demonstrated
1. ✅ Clean Architecture principles
2. ✅ SOLID principles (especially Single Responsibility)
3. ✅ Proper async/await usage
4. ✅ SwiftUI best practices (@FetchRequest, @Published)
5. ✅ Comprehensive localization
6. ✅ Backwards compatibility (deprecated instead of deleted)
7. ✅ Documentation and commit hygiene
8. ✅ Testability through dependency injection

203
docs/Tags-Sync.md Normal file
View File

@ -0,0 +1,203 @@
# Tags Synchronization
This document describes how tags (labels) are synchronized and updated throughout the readeck app.
## Overview
The app uses a **cache-first strategy** with background synchronization to ensure fast UI responses while keeping data up-to-date with the server.
## Architecture
### Components
1. **Core Data Storage** (`TagEntity`)
- Local persistent storage for all tags
- Fields: `name` (String), `count` (Int32)
- Used as the single source of truth for all UI components
2. **LabelsRepository**
- Manages tag synchronization between API and Core Data
- Implements cache-first loading strategy
3. **CoreDataTagManagementView**
- SwiftUI view component for tag management
- Uses `@FetchRequest` to directly query Core Data
- Automatically updates when Core Data changes
4. **LabelsView**
- Full-screen tag list view
- Accessible via "More" → "Tags" tab
- Triggers manual tag synchronization
## Synchronization Flow
### When Tags are Fetched
Tags are synchronized in the following scenarios:
#### 1. Opening the Tags Tab
**Trigger**: User navigates to "More" → "Tags"
**Location**: `LabelsView.swift:43-46`
```swift
.onAppear {
Task {
await viewModel.loadLabels()
}
}
```
**Process**:
1. Immediately loads tags from Core Data (instant response)
2. Starts background API call to fetch latest tags
3. Updates Core Data if API call succeeds
4. Silently fails if server is unreachable (keeps cached data)
#### 2. Background Sync Strategy
**Implementation**: `LabelsRepository.getLabels()`
The repository uses a two-phase approach:
**Phase 1: Instant Response**
```swift
let cachedLabels = try await loadLabelsFromCoreData()
```
- Returns immediately with cached data
- Ensures UI is never blocked
**Phase 2: Background Update**
```swift
Task.detached(priority: .background) {
let dtos = try await self.api.getBookmarkLabels()
try? await self.saveLabels(dtos)
}
```
- Runs asynchronously in background
- Updates Core Data with latest server data
- Silent failure - no error shown to user if sync fails
#### 3. Adding a New Bookmark
**Trigger**: User opens "Add Bookmark" sheet
**Location**: `AddBookmarkView.swift:61-66`
```swift
.onAppear {
viewModel.checkClipboard()
Task {
await viewModel.syncTags()
}
}
```
**Process**:
1. Triggers background sync when view appears
2. `CoreDataTagManagementView` shows cached tags immediately
3. View automatically updates via `@FetchRequest` when sync completes
#### 4. Editing Bookmark Labels
**Trigger**: User opens "Manage Labels" sheet from bookmark detail
**Location**: `BookmarkLabelsView.swift:49-53`
```swift
.onAppear {
Task {
await viewModel.syncTags()
}
}
```
**Process**:
1. Triggers background sync when view appears
2. `CoreDataTagManagementView` shows cached tags immediately
3. View automatically updates via `@FetchRequest` when sync completes
#### 5. Share Extension
Tags are **not** synced in the Share Extension:
- Uses cached tags from Core Data only
- No API calls to minimize extension launch time
- Relies on tags synced by main app
**Reason**: Share Extensions should be fast and lightweight. Tags are already synchronized by the main app when opening tags tab or managing bookmark labels.
### When Core Data Updates
Core Data tag updates trigger automatic UI refreshes in all views using `@FetchRequest`:
- `CoreDataTagManagementView`
- `LabelsView`
This happens when:
- Background sync completes successfully
- New tags are created via bookmark operations
- Tag counts change due to bookmark label modifications
## Tag Display Configuration
### Share Extension
- **Fixed sorting**: Always by usage count (`.byCount`)
- **Display limit**: Top 150 tags
- **Label**: "Most used tags"
- **Rationale**: Quick access to most frequently used tags for fast bookmark creation
### Main App
- **User-configurable sorting**: Either by usage count or alphabetically
- **Display limit**: All tags (no limit)
- **Setting location**: Settings → Appearance → Tag Sort Order
- **Labels**:
- "Sorted by usage count" (when `.byCount`)
- "Sorted alphabetically" (when `.alphabetically`)
## Data Persistence
### Core Data Updates
Tags in Core Data are updated through:
1. **Batch sync** (`LabelsRepository.saveLabels`)
- Compares existing tags with new data from server
- Updates counts for existing tags
- Inserts new tags
- Only saves if changes detected
2. **Efficiency optimizations**:
- Batch fetch of existing entities
- Dictionary-based lookups for fast comparison
- Conditional saves to minimize disk I/O
## Error Handling
### Network Failures
- **Behavior**: Silent failure
- **User Experience**: App continues to work with cached data
- **Rationale**: Tags are not critical for app functionality; offline access is prioritized
### Core Data Errors
- **Read errors**: UI shows empty state or cached data
- **Write errors**: Logged but do not block UI operations
## Implementation Notes
### Deprecated Components
- `LegacyTagManagementView.swift`: Old API-based tag management (marked for removal)
- `TagManagementView.swift`: Deleted, replaced by `CoreDataTagManagementView.swift`
### Key Differences: New vs Old Approach
**Old (LegacyTagManagementView)**:
- Fetched tags from API on every view appearance
- Slower initial load
- Required network connectivity
- More server load
**New (CoreDataTagManagementView)**:
- Uses Core Data with `@FetchRequest`
- Instant UI response
- Works offline
- Automatic UI updates via SwiftUI reactivity
- Reduced server load through background sync
## Future Considerations
1. **Offline tag creation**: Currently, new tags can be created offline but won't sync until server is reachable
2. **Tag deletion**: Not implemented in current version
3. **Tag renaming**: Not implemented in current version
4. **Conflict resolution**: Tags created offline with same name as server tags will merge on sync

18
docs/tabbar.md Normal file
View File

@ -0,0 +1,18 @@
## Feature: TabView nur in Root-Screens sichtbar, nicht in Detail-Views
### Beschreibung
Die App verwendet aktuell eine `TabView` (bzw. einen Tab Controller), die global sichtbar ist.
Der Workflow funktioniert grundsätzlich, allerdings wird die `TabView` auch dann angezeigt, wenn ein Benutzer von einem Tab in eine **Detailansicht** (z. B. Artikel-Detail, Item-Detail) navigiert.
Ziel ist es, dass die `TabView` **nur in den Root-Views** sichtbar ist.
Beim Öffnen einer **Detail-Ansicht** soll die `TabView` automatisch ausgeblendet werden, damit dort alternativ eine eigene **Bottom Toolbar** angezeigt werden kann.
### Akzeptanzkriterien
- `TabView` ist standardmäßig in den Haupt-Tabs sichtbar.
- Navigiert der Nutzer in eine Detail-Ansicht (z. B. Rom Detail), wird die `TabView` ausgeblendet.
- In den Detail-Ansichten kann ein eigener `Toolbar` oder eine Custom Bottom Bar sichtbar sein - ist aber kein teil von diesem task.
- Navigation zurück zur Root-View blendet die `TabView` wieder ein.
# Technischer hinweis
To hide TabBar when we jumps towards next screen we just have to place NavigationView to the right place. Makesure Embed TabView inside NavigationView so creating unique Navigationview for both tabs.

View File

@ -9,9 +9,9 @@
/* Begin PBXBuildFile section */
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; };
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -67,7 +67,6 @@
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -83,16 +82,30 @@
membershipExceptions = (
Assets.xcassets,
Data/CoreData/CoreDataManager.swift,
"Data/Extensions/NSManagedObjectContext+SafeFetch.swift",
Data/KeychainHelper.swift,
Data/Utils/LabelUtils.swift,
Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift,
Logger.swift,
Domain/Model/CardLayoutStyle.swift,
Domain/Model/FontFamily.swift,
Domain/Model/FontSize.swift,
Domain/Model/Settings.swift,
Domain/Model/TagSortOrder.swift,
Domain/Model/Theme.swift,
Domain/Model/UrlOpener.swift,
readeck.xcdatamodeld,
Splash.storyboard,
UI/Components/Constants.swift,
UI/Components/CoreDataTagManagementView.swift,
UI/Components/CustomTextFieldStyle.swift,
UI/Components/TagManagementView.swift,
UI/Components/LegacyTagManagementView.swift,
UI/Components/UnifiedLabelChip.swift,
UI/Extension/FontSizeExtension.swift,
UI/Models/AppSettings.swift,
UI/Utils/NotificationNames.swift,
Utils/Logger.swift,
Utils/LogStore.swift,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};
@ -147,8 +160,10 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -172,7 +187,6 @@
5D45F9BF2DF858680048D5B8 = {
isa = PBXGroup;
children = (
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
5D45F9CA2DF858680048D5B8 /* readeck */,
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
@ -240,6 +254,8 @@
packageProductDependencies = (
5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5D9D95482E623668009AF769 /* Kingfisher */,
5D48E6012EB402F50043F90F /* MarkdownUI */,
);
productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -330,6 +346,8 @@
packageReferences = (
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -349,7 +367,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -357,7 +374,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -436,7 +452,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -449,7 +465,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -469,7 +485,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -482,7 +498,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -624,7 +640,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -647,7 +663,7 @@
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;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -668,7 +684,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -691,7 +707,7 @@
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;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -853,6 +869,22 @@
minimumVersion = 1.21.0;
};
};
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.4.1;
};
};
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 8.5.0;
};
};
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
@ -869,6 +901,16 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = netfox;
};
5D48E6012EB402F50043F90F /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
5D9D95482E623668009AF769 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;

View File

@ -1,6 +1,15 @@
{
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635",
"pins" : [
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "2015fda791daa72c8058619545a593bf8c1dd59f",
"version" : "8.5.0"
}
},
{
"identity" : "netfox",
"kind" : "remoteSourceControl",
@ -10,6 +19,15 @@
"version" : "1.21.0"
}
},
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "r.swift",
"kind" : "remoteSourceControl",
@ -28,6 +46,24 @@
"version" : "1.6.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
},
{
"identity" : "xcodeedit",
"kind" : "remoteSourceControl",

View File

@ -2,16 +2,7 @@
"images" : [
{
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
"idiom" : "universal"
}
],
"info" : {

View File

@ -18,6 +18,9 @@ protocol PAPI {
func deleteBookmark(id: String) async throws
func searchBookmarks(search: String) async throws -> BookmarksPageDto
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
}
class API: PAPI {
@ -41,6 +44,14 @@ class API: PAPI {
return url
}
}
private func handleUnauthorizedResponse(_ statusCode: Int) {
if statusCode == 401 {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
}
}
}
private func makeJSONRequestWithHeaders<T: Codable>(
endpoint: String,
@ -74,6 +85,7 @@ class API: PAPI {
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
throw APIError.serverError(httpResponse.statusCode)
}
@ -114,6 +126,7 @@ class API: PAPI {
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
throw APIError.serverError(httpResponse.statusCode)
}
@ -146,6 +159,7 @@ class API: PAPI {
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
throw APIError.serverError(httpResponse.statusCode)
}
@ -181,6 +195,7 @@ class API: PAPI {
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
@ -229,7 +244,9 @@ class API: PAPI {
}
if let tag {
queryItems.append(URLQueryItem(name: "labels", value: tag))
// URL-encode label with quotes for proper API handling
let encodedTag = "\"\(tag)\""
queryItems.append(URLQueryItem(name: "labels", value: encodedTag))
}
if !queryItems.isEmpty {
@ -342,6 +359,7 @@ class API: PAPI {
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
@ -379,6 +397,7 @@ class API: PAPI {
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
@ -419,15 +438,93 @@ class API: PAPI {
logger.debug("Fetching bookmark labels")
let endpoint = "/api/bookmarks/labels"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let result = try await makeJSONRequest(
endpoint: endpoint,
responseType: [BookmarkLabelDto].self
)
logger.info("Successfully fetched \(result.count) bookmark labels")
return result
}
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] {
logger.debug("Fetching annotations for bookmark: \(bookmarkId)")
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let result = try await makeJSONRequest(
endpoint: endpoint,
responseType: [AnnotationDto].self
)
logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)")
return result
}
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto {
logger.debug("Creating annotation for bookmark: \(bookmarkId)")
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
let bodyDict: [String: Any] = [
"color": color,
"start_offset": startOffset,
"end_offset": endOffset,
"start_selector": startSelector,
"end_selector": endSelector
]
let bodyData = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
let result = try await makeJSONRequest(
endpoint: endpoint,
method: .POST,
body: bodyData,
responseType: AnnotationDto.self
)
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
return result
}
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
logger.info("Deleting annotation: \(annotationId) from bookmark: \(bookmarkId)")
let baseURL = await self.baseURL
let fullEndpoint = "/api/bookmarks/\(bookmarkId)/annotations/\(annotationId)"
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = await tokenProvider.getToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for DELETE \(url.absoluteString)")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
logger.info("Successfully deleted annotation: \(annotationId)")
}
}
enum HTTPMethod: String {

View File

@ -0,0 +1,21 @@
import Foundation
struct AnnotationDto: Codable {
let id: String
let text: String
let created: String
let startOffset: Int
let endOffset: Int
let startSelector: String
let endSelector: String
enum CodingKeys: String, CodingKey {
case id
case text
case created
case startOffset = "start_offset"
case endOffset = "end_offset"
case startSelector = "start_selector"
case endSelector = "end_selector"
}
}

View File

@ -0,0 +1,13 @@
import Foundation
struct ServerInfoDto: Codable {
let version: String
let buildDate: String?
let userAgent: String?
enum CodingKeys: String, CodingKey {
case version
case buildDate = "build_date"
case userAgent = "user_agent"
}
}

View File

@ -0,0 +1,55 @@
//
// InfoApiClient.swift
// readeck
//
// Created by Claude Code
import Foundation
protocol PInfoApiClient {
func getServerInfo() async throws -> ServerInfoDto
}
class InfoApiClient: PInfoApiClient {
private let tokenProvider: TokenProvider
private let logger = Logger.network
init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
self.tokenProvider = tokenProvider
}
func getServerInfo() async throws -> ServerInfoDto {
guard let endpoint = await tokenProvider.getEndpoint(),
let url = URL(string: "\(endpoint)/api/info") else {
logger.error("Invalid endpoint URL for server info")
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "accept")
request.timeoutInterval = 5.0
if let token = await tokenProvider.getToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
}
logger.logNetworkRequest(method: "GET", url: url.absoluteString)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for server info")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
logger.logNetworkError(method: "GET", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
logger.logNetworkRequest(method: "GET", url: url.absoluteString, statusCode: httpResponse.statusCode)
return try JSONDecoder().decode(ServerInfoDto.self, from: data)
}
}

View File

@ -43,6 +43,11 @@ class CoreDataManager {
self?.logger.info("Core Data persistent store loaded successfully")
}
}
// Configure viewContext for better extension support
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
@ -50,6 +55,16 @@ class CoreDataManager {
return persistentContainer.viewContext
}
var mainContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.automaticallyMergesChangesFromParent = true
return context
}
func save() {
if context.hasChanges {
do {

View File

@ -0,0 +1,77 @@
//
// NSManagedObjectContext+SafeFetch.swift
// readeck
//
// Created by Ilyas Hallak on 25.07.25.
//
// SPDX-License-Identifier: MIT
//
// This file is part of the readeck project and is licensed under the MIT License.
//
import CoreData
import Foundation
extension NSManagedObjectContext {
/// Thread-safe fetch that automatically wraps the operation in performAndWait
func safeFetch<T: NSManagedObject>(_ request: NSFetchRequest<T>) throws -> [T] {
var results: [T] = []
var fetchError: Error?
performAndWait {
do {
results = try self.fetch(request)
} catch {
fetchError = error
}
}
if let error = fetchError {
throw error
}
return results
}
/// Thread-safe perform operation with return value
func safePerform<T>(_ operation: @escaping @Sendable () throws -> T) throws -> T {
var result: T?
var operationError: Error?
performAndWait {
do {
result = try operation()
} catch {
operationError = error
}
}
if let error = operationError {
throw error
}
guard let unwrappedResult = result else {
throw NSError(domain: "SafePerformError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Operation returned nil"])
}
return unwrappedResult
}
/// Thread-safe perform operation without return value
func safePerform(_ operation: @escaping () throws -> Void) throws {
var operationError: Error?
performAndWait {
do {
try operation()
} catch {
operationError = error
}
}
if let error = operationError {
throw error
}
}
}

View File

@ -0,0 +1,13 @@
import Foundation
extension String {
/// Returns a localized version of the string using NSLocalizedString
var localized: String {
return NSLocalizedString(self, comment: "")
}
/// Returns a localized version of the string with comment
func localized(comment: String) -> String {
return NSLocalizedString(self, comment: comment)
}
}

View File

@ -9,11 +9,12 @@ import Foundation
import CoreData
extension BookmarkLabelDto {
@discardableResult
func toEntity(context: NSManagedObjectContext) -> TagEntity {
let entity = TagEntity(context: context)
entity.name = name
entity.count = Int32(count)
return entity
}
}

View File

@ -0,0 +1,28 @@
import Foundation
class AnnotationsRepository: PAnnotationsRepository {
private let api: PAPI
init(api: PAPI) {
self.api = api
}
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] {
let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId)
return annotationDtos.map { dto in
Annotation(
id: dto.id,
text: dto.text,
created: dto.created,
startOffset: dto.startOffset,
endOffset: dto.endOffset,
startSelector: dto.startSelector,
endSelector: dto.endSelector
)
}
}
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -1,7 +1,7 @@
import Foundation
import CoreData
class LabelsRepository: PLabelsRepository {
class LabelsRepository: PLabelsRepository, @unchecked Sendable {
private let api: PAPI
private let coreDataManager = CoreDataManager.shared
@ -11,33 +11,107 @@ class LabelsRepository: PLabelsRepository {
}
func getLabels() async throws -> [BookmarkLabel] {
let dtos = try await api.getBookmarkLabels()
try? await saveLabels(dtos)
return dtos.map { $0.toDomain() }
// First, load from Core Data (instant response)
let cachedLabels = try await loadLabelsFromCoreData()
// Then sync with API in background (don't wait)
Task.detached(priority: .background) { [weak self] in
guard let self = self else { return }
do {
let dtos = try await self.api.getBookmarkLabels()
try? await self.saveLabels(dtos)
} catch {
// Silent fail - we already have cached data
}
}
return cachedLabels
}
private func loadLabelsFromCoreData() async throws -> [BookmarkLabel] {
let backgroundContext = coreDataManager.newBackgroundContext()
return try await backgroundContext.perform {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "count", ascending: false),
NSSortDescriptor(key: "name", ascending: true)
]
let entities = try backgroundContext.fetch(fetchRequest)
return entities.compactMap { entity -> BookmarkLabel? in
guard let name = entity.name, !name.isEmpty else { return nil }
return BookmarkLabel(
name: name,
count: Int(entity.count),
href: name
)
}
}
}
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
for dto in dtos {
if !tagExists(name: dto.name) {
dto.toEntity(context: coreDataManager.context)
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform {
// Batch fetch all existing labels
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name", "count"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
var existingByName: [String: TagEntity] = [:]
for entity in existingEntities {
if let name = entity.name {
existingByName[name] = entity
}
}
// Insert or update labels
var insertCount = 0
var updateCount = 0
for dto in dtos {
if let existing = existingByName[dto.name] {
// Update count if changed
if existing.count != dto.count {
existing.count = Int32(dto.count)
updateCount += 1
}
} else {
// Insert new label
dto.toEntity(context: backgroundContext)
insertCount += 1
}
}
// Only save if there are changes
if insertCount > 0 || updateCount > 0 {
try backgroundContext.save()
}
}
try coreDataManager.context.save()
}
private func tagExists(name: String) -> Bool {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
var exists = false
coreDataManager.context.performAndWait {
do {
let results = try coreDataManager.context.fetch(fetchRequest)
exists = !results.isEmpty
} catch {
exists = false
func saveNewLabel(name: String) async throws {
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// Check if label already exists
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
fetchRequest.fetchLimit = 1
let existingTags = try backgroundContext.fetch(fetchRequest)
// Only create if it doesn't exist
if existingTags.isEmpty {
let newTag = TagEntity(context: backgroundContext)
newTag.name = trimmedName
newTag.count = 1 // New label is being used immediately
try backgroundContext.save()
}
}
return exists
}
}

View File

@ -2,24 +2,27 @@ import Foundation
import CoreData
import SwiftUI
class OfflineSyncManager: ObservableObject {
class OfflineSyncManager: ObservableObject, @unchecked Sendable {
static let shared = OfflineSyncManager()
@Published var isSyncing = false
@Published var syncStatus: String?
private let coreDataManager = CoreDataManager.shared
private let api: PAPI
init(api: PAPI = API()) {
private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase
init(api: PAPI = API(),
checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) {
self.api = api
self.checkServerReachabilityUseCase = checkServerReachabilityUseCase
}
// MARK: - Sync Methods
func syncOfflineBookmarks() async {
// First check if server is reachable
guard await ServerConnectivity.isServerReachable() else {
guard await checkServerReachabilityUseCase.execute() else {
await MainActor.run {
isSyncing = false
syncStatus = "Server not reachable. Cannot sync."
@ -99,10 +102,9 @@ class OfflineSyncManager: ObservableObject {
}
private func getOfflineBookmarks() -> [ArticleURLEntity] {
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
do {
return try coreDataManager.context.fetch(fetchRequest)
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
return try coreDataManager.context.safeFetch(fetchRequest)
} catch {
print("Failed to fetch offline bookmarks: \(error)")
return []
@ -110,26 +112,16 @@ class OfflineSyncManager: ObservableObject {
}
private func deleteOfflineBookmark(_ entity: ArticleURLEntity) {
coreDataManager.context.delete(entity)
coreDataManager.save()
}
// MARK: - Auto Sync on Server Connectivity Changes
func startAutoSync() {
// Monitor server connectivity and auto-sync when server becomes reachable
NotificationCenter.default.addObserver(
forName: NSNotification.Name("ServerDidBecomeAvailable"),
object: nil,
queue: .main
) { [weak self] _ in
Task {
await self?.syncOfflineBookmarks()
do {
try coreDataManager.context.safePerform { [weak self] in
guard let self = self else { return }
self.coreDataManager.context.delete(entity)
self.coreDataManager.save()
}
} catch {
print("Failed to delete offline bookmark: \(error)")
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}

View File

@ -0,0 +1,114 @@
//
// ServerInfoRepository.swift
// readeck
//
// Created by Claude Code
import Foundation
class ServerInfoRepository: PServerInfoRepository {
private let apiClient: PInfoApiClient
private let logger = Logger.network
// Cache properties
private var cachedServerInfo: ServerInfo?
private var lastCheckTime: Date?
private let cacheTTL: TimeInterval = 30.0 // 30 seconds cache
private let rateLimitInterval: TimeInterval = 5.0 // min 5 seconds between requests
// Thread safety
private let queue = DispatchQueue(label: "com.readeck.serverInfoRepository", attributes: .concurrent)
init(apiClient: PInfoApiClient) {
self.apiClient = apiClient
}
func checkServerReachability() async -> Bool {
// Check cache first
if let cached = getCachedReachability() {
logger.debug("Server reachability from cache: \(cached)")
return cached
}
// Check rate limiting
if isRateLimited() {
logger.debug("Server reachability check rate limited, using cached value")
return cachedServerInfo?.isReachable ?? false
}
// Perform actual check
do {
let info = try await apiClient.getServerInfo()
let serverInfo = ServerInfo(from: info)
updateCache(serverInfo: serverInfo)
logger.info("Server reachability checked: true (version: \(info.version))")
return true
} catch {
let unreachableInfo = ServerInfo.unreachable
updateCache(serverInfo: unreachableInfo)
logger.warning("Server reachability check failed: \(error.localizedDescription)")
return false
}
}
func getServerInfo() async throws -> ServerInfo {
// Check cache first
if let cached = getCachedServerInfo() {
logger.debug("Server info from cache")
return cached
}
// Check rate limiting
if isRateLimited(), let cached = cachedServerInfo {
logger.debug("Server info check rate limited, using cached value")
return cached
}
// Fetch fresh info
let dto = try await apiClient.getServerInfo()
let serverInfo = ServerInfo(from: dto)
updateCache(serverInfo: serverInfo)
logger.info("Server info fetched: version \(dto.version)")
return serverInfo
}
// MARK: - Cache Management
private func getCachedReachability() -> Bool? {
queue.sync {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedServerInfo else {
return nil
}
return cached.isReachable
}
}
private func getCachedServerInfo() -> ServerInfo? {
queue.sync {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedServerInfo else {
return nil
}
return cached
}
}
private func isRateLimited() -> Bool {
queue.sync {
guard let lastCheck = lastCheckTime else {
return false
}
return Date().timeIntervalSince(lastCheck) < rateLimitInterval
}
}
private func updateCache(serverInfo: ServerInfo) {
queue.async(flags: .barrier) { [weak self] in
self?.cachedServerInfo = serverInfo
self?.lastCheckTime = Date()
}
}
}

View File

@ -1,27 +1,6 @@
import Foundation
import CoreData
struct Settings {
var endpoint: String? = nil
var username: String? = nil
var password: String? = nil
var token: String? = nil
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
}
mutating func setToken(_ newToken: String) {
token = newToken
}
}
protocol PSettingsRepository {
func saveSettings(_ settings: Settings) async throws
func loadSettings() async throws -> Settings?
@ -30,7 +9,11 @@ protocol PSettingsRepository {
func saveUsername(_ username: String) async throws
func savePassword(_ password: String) async throws
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
func loadCardLayoutStyle() async throws -> CardLayoutStyle
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
func loadTagSortOrder() async throws -> TagSortOrder
var hasFinishedSetup: Bool { get }
}
@ -39,6 +22,15 @@ class SettingsRepository: PSettingsRepository {
private let userDefault = UserDefaults.standard
private let keychainHelper = KeychainHelper.shared
var hasFinishedSetup: Bool {
get {
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
}
set {
userDefault.set(newValue, forKey: "hasFinishedSetup")
}
}
func saveSettings(_ settings: Settings) async throws {
// Save credentials to keychain
if let endpoint = settings.endpoint, !endpoint.isEmpty {
@ -79,6 +71,18 @@ class SettingsRepository: PSettingsRepository {
existingSettings.theme = theme.rawValue
}
if let urlOpener = settings.urlOpener {
existingSettings.urlOpener = urlOpener.rawValue
}
if let cardLayoutStyle = settings.cardLayoutStyle {
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
}
if let tagSortOrder = settings.tagSortOrder {
existingSettings.tagSortOrder = tagSortOrder.rawValue
}
try context.save()
continuation.resume()
} catch {
@ -115,7 +119,10 @@ class SettingsRepository: PSettingsRepository {
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
enableTTS: settingEntity?.enableTTS,
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue)
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue),
tagSortOrder: TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue),
urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue)
)
continuation.resume(returning: settings)
} catch {
@ -160,7 +167,7 @@ class SettingsRepository: PSettingsRepository {
self.hasFinishedSetup = true
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
}
}
@ -174,7 +181,7 @@ class SettingsRepository: PSettingsRepository {
if !token.isEmpty {
self.hasFinishedSetup = true
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
}
}
@ -192,18 +199,91 @@ class SettingsRepository: PSettingsRepository {
self.hasFinishedSetup = hasFinishedSetup
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
continuation.resume()
}
}
var hasFinishedSetup: Bool {
get {
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
set {
userDefault.set(newValue, forKey: "hasFinishedSetup")
}
func loadCardLayoutStyle() async throws -> CardLayoutStyle {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first
let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine
continuation.resume(returning: cardLayoutStyle)
} catch {
continuation.resume(throwing: error)
}
}
}
}
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
existingSettings.tagSortOrder = tagSortOrder.rawValue
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func loadTagSortOrder() async throws -> TagSortOrder {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first
let tagSortOrder = TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue) ?? .byCount
continuation.resume(returning: tagSortOrder)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}

View File

@ -10,19 +10,38 @@ protocol TokenProvider {
class KeychainTokenProvider: TokenProvider {
private let keychainHelper = KeychainHelper.shared
// Cache to avoid repeated keychain access
private var cachedToken: String?
private var cachedEndpoint: String?
func getToken() async -> String? {
return keychainHelper.loadToken()
if let cached = cachedToken {
return cached
}
let token = keychainHelper.loadToken()
cachedToken = token
return token
}
func getEndpoint() async -> String? {
return keychainHelper.loadEndpoint()
if let cached = cachedEndpoint {
return cached
}
let endpoint = keychainHelper.loadEndpoint()
cachedEndpoint = endpoint
return endpoint
}
func setToken(_ token: String) async {
keychainHelper.saveToken(token)
cachedToken = token
}
func clearToken() async {
keychainHelper.clearCredentials()
cachedToken = nil
cachedEndpoint = nil
}
}

View File

@ -0,0 +1,27 @@
import Foundation
struct LabelUtils {
/// Processes a label input string and returns it as a single trimmed label
/// - Parameter input: The input string containing a label (spaces are allowed)
/// - Returns: Array containing the trimmed label, or empty array if input is empty
static func splitLabelsFromInput(_ input: String) -> [String] {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? [] : [trimmed]
}
/// Filters out labels that already exist in current or available labels
/// - Parameters:
/// - labels: Array of labels to filter
/// - currentLabels: Currently selected labels
/// - availableLabels: Available labels (optional)
/// - Returns: Array of unique labels that don't already exist
static func filterUniqueLabels(_ labels: [String], currentLabels: [String], availableLabels: [String] = []) -> [String] {
let currentSet = Set(currentLabels.map { $0.lowercased() })
let availableSet = Set(availableLabels.map { $0.lowercased() })
return labels.filter { label in
let lowercased = label.lowercased()
return !currentSet.contains(lowercased) && !availableSet.contains(lowercased)
}
}
}

View File

@ -1,92 +0,0 @@
import Foundation
import Network
class ServerConnectivity: ObservableObject {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue.global(qos: .background)
@Published var isServerReachable = false
static let shared = ServerConnectivity()
private init() {
startMonitoring()
}
private func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
if path.status == .satisfied {
// Network is available, now check server
Task {
let serverReachable = await ServerConnectivity.isServerReachable()
DispatchQueue.main.async {
let wasReachable = self?.isServerReachable ?? false
self?.isServerReachable = serverReachable
// Notify when server becomes available
if !wasReachable && serverReachable {
NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil)
}
}
}
} else {
DispatchQueue.main.async {
self?.isServerReachable = false
}
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
// Check if the Readeck server endpoint is reachable
static func isServerReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint + "/api/health") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 5.0 // 5 second timeout
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
// Fallback: try basic endpoint if health endpoint doesn't exist
return await isBasicEndpointReachable()
}
return false
}
private static func isBasicEndpointReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint) else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 3.0
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode < 500
}
} catch {
print("Server connectivity check failed: \(error)")
}
return false
}
}

View File

@ -0,0 +1,19 @@
import Foundation
struct Annotation: Identifiable, Hashable {
let id: String
let text: String
let created: String
let startOffset: Int
let endOffset: Int
let startSelector: String
let endSelector: String
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Annotation, rhs: Annotation) -> Bool {
lhs.id == rhs.id
}
}

View File

@ -0,0 +1,29 @@
import Foundation
enum CardLayoutStyle: String, CaseIterable, Codable {
case compact = "compact"
case magazine = "magazine"
case natural = "natural"
var displayName: String {
switch self {
case .compact:
return "Compact"
case .magazine:
return "Magazine"
case .natural:
return "Natural"
}
}
var description: String {
switch self {
case .compact:
return "Small thumbnails with content focus"
case .magazine:
return "Fixed height headers for consistent layout"
case .natural:
return "Images in original aspect ratio"
}
}
}

View File

@ -0,0 +1,23 @@
//
// FontFamily.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}

View File

@ -0,0 +1,33 @@
//
// FontSize.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
import Foundation
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
}

View File

@ -0,0 +1,21 @@
import Foundation
struct ServerInfo {
let version: String
let buildDate: String?
let userAgent: String?
let isReachable: Bool
}
extension ServerInfo {
init(from dto: ServerInfoDto) {
self.version = dto.version
self.buildDate = dto.buildDate
self.userAgent = dto.userAgent
self.isReachable = true
}
static var unreachable: ServerInfo {
ServerInfo(version: "", buildDate: nil, userAgent: nil, isReachable: false)
}
}

View File

@ -0,0 +1,32 @@
//
// Settings.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
struct Settings {
var endpoint: String? = nil
var username: String? = nil
var password: String? = nil
var token: String? = nil
var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var tagSortOrder: TagSortOrder? = nil
var urlOpener: UrlOpener? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
}
mutating func setToken(_ newToken: String) {
token = newToken
}
}

View File

@ -0,0 +1,20 @@
//
// TagSortOrder.swift
// readeck
//
// Created by Ilyas Hallak
//
import Foundation
enum TagSortOrder: String, CaseIterable {
case byCount = "count"
case alphabetically = "alphabetically"
var displayName: String {
switch self {
case .byCount: return "By usage count"
case .alphabetically: return "Alphabetically"
}
}
}

View File

@ -0,0 +1,18 @@
//
// UrlOpener.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
enum UrlOpener: String, CaseIterable {
case inAppBrowser = "inAppBrowser"
case defaultBrowser = "defaultBrowser"
var displayName: String {
switch self {
case .inAppBrowser: return "In App Browser"
case .defaultBrowser: return "Default Browser"
}
}
}

View File

@ -0,0 +1,4 @@
protocol PAnnotationsRepository {
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
}

View File

@ -3,4 +3,5 @@ import Foundation
protocol PLabelsRepository {
func getLabels() async throws -> [BookmarkLabel]
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
func saveNewLabel(name: String) async throws
}

View File

@ -0,0 +1,10 @@
//
// PServerInfoRepository.swift
// readeck
//
// Created by Claude Code
protocol PServerInfoRepository {
func checkServerReachability() async -> Bool
func getServerInfo() async throws -> ServerInfo
}

View File

@ -0,0 +1,28 @@
//
// CheckServerReachabilityUseCase.swift
// readeck
//
// Created by Claude Code
import Foundation
protocol PCheckServerReachabilityUseCase {
func execute() async -> Bool
func getServerInfo() async throws -> ServerInfo
}
class CheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
private let repository: PServerInfoRepository
init(repository: PServerInfoRepository) {
self.repository = repository
}
func execute() async -> Bool {
return await repository.checkServerReachability()
}
func getServerInfo() async throws -> ServerInfo {
return try await repository.getServerInfo()
}
}

View File

@ -0,0 +1,17 @@
import Foundation
protocol PCreateLabelUseCase {
func execute(name: String) async throws
}
class CreateLabelUseCase: PCreateLabelUseCase {
private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) {
self.labelsRepository = labelsRepository
}
func execute(name: String) async throws {
try await labelsRepository.saveNewLabel(name: name)
}
}

View File

@ -0,0 +1,17 @@
import Foundation
protocol PDeleteAnnotationUseCase {
func execute(bookmarkId: String, annotationId: String) async throws
}
class DeleteAnnotationUseCase: PDeleteAnnotationUseCase {
private let repository: PAnnotationsRepository
init(repository: PAnnotationsRepository) {
self.repository = repository
}
func execute(bookmarkId: String, annotationId: String) async throws {
try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -0,0 +1,17 @@
import Foundation
protocol PGetBookmarkAnnotationsUseCase {
func execute(bookmarkId: String) async throws -> [Annotation]
}
class GetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
private let repository: PAnnotationsRepository
init(repository: PAnnotationsRepository) {
self.repository = repository
}
func execute(bookmarkId: String) async throws -> [Annotation] {
return try await repository.fetchAnnotations(bookmarkId: bookmarkId)
}
}

View File

@ -0,0 +1,21 @@
import Foundation
protocol PLoadCardLayoutUseCase {
func execute() async -> CardLayoutStyle
}
class LoadCardLayoutUseCase: PLoadCardLayoutUseCase {
private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute() async -> CardLayoutStyle {
do {
return try await settingsRepository.loadCardLayoutStyle()
} catch {
return .magazine
}
}
}

View File

@ -0,0 +1,22 @@
import Foundation
protocol PSaveCardLayoutUseCase {
func execute(layout: CardLayoutStyle) async
}
class SaveCardLayoutUseCase: PSaveCardLayoutUseCase {
private let settingsRepository: PSettingsRepository
private let logger = Logger.data
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute(layout: CardLayoutStyle) async {
do {
try await settingsRepository.saveCardLayoutStyle(layout)
} catch {
logger.error("Failed to save card layout style: \(error)")
}
}
}

View File

@ -4,6 +4,7 @@ protocol PSaveSettingsUseCase {
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
func execute(enableTTS: Bool) async throws
func execute(theme: Theme) async throws
func execute(urlOpener: UrlOpener) async throws
}
class SaveSettingsUseCase: PSaveSettingsUseCase {
@ -33,4 +34,10 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
.init(theme: theme)
)
}
func execute(urlOpener: UrlOpener) async throws {
try await settingsRepository.saveSettings(
.init(urlOpener: urlOpener)
)
}
}

View File

@ -0,0 +1,21 @@
import Foundation
protocol PSyncTagsUseCase {
func execute() async throws
}
/// Triggers background synchronization of tags from server to Core Data
/// Uses cache-first strategy - returns immediately after triggering sync
class SyncTagsUseCase: PSyncTagsUseCase {
private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) {
self.labelsRepository = labelsRepository
}
func execute() async throws {
// Trigger the sync - getLabels() uses cache-first + background sync strategy
// We don't need the return value, just triggering the sync is enough
_ = try await labelsRepository.getLabels()
}
}

View File

@ -0,0 +1,155 @@
/*
Localizable.strings
readeck
Created by conversion from Localizable.xcstrings
*/
"" = "";
"(%lld found)" = "(%lld found)";
"%" = "%";
"%@ (%lld)" = "%1$@ (%2$lld)";
"%lld" = "%lld";
"%lld articles in the queue" = "%lld articles in the queue";
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
"%lld min" = "%lld min";
"%lld." = "%lld.";
"%lld/%lld" = "%1$lld/%2$lld";
"12 min • Today • example.com" = "12 min • Today • example.com";
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
/* Legal & Privacy */
"Legal & Privacy" = "Legal & Privacy";
"Privacy Policy" = "Privacy Policy";
"Legal Notice" = "Legal Notice";
"Report an Issue" = "Report an Issue";
"Contact Support" = "Contact Support";
/* Navigation & States */
"All" = "All";
"Unread" = "Unread";
"Favorites" = "Favorites";
"Archive" = "Archive";
"Search" = "Search";
"Settings" = "Settings";
"Articles" = "Articles";
"Videos" = "Videos";
"Pictures" = "Pictures";
"Tags" = "Tags";
/* Settings Sections */
"Font Settings" = "Font Settings";
"Appearance" = "Appearance";
"Cache Settings" = "Cache Settings";
"General Settings" = "General Settings";
"Server Settings" = "Server Settings";
"Server Connection" = "Server Connection";
"Add" = "Add";
"Add new tag:" = "Add new tag:";
"all" = "all";
"All tags selected" = "All tags selected";
"Archive" = "Archive";
"Archive bookmark" = "Archive bookmark";
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
"Available tags" = "Available tags";
"Cancel" = "Cancel";
"Category-specific Levels" = "Category-specific Levels";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
"Close" = "Close";
"Configure log levels and categories" = "Configure log levels and categories";
"Critical" = "Critical";
"Debug" = "Debug";
"DEBUG BUILD" = "DEBUG BUILD";
"Debug Settings" = "Debug Settings";
"Delete" = "Delete";
"Delete Bookmark" = "Delete Bookmark";
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
"Done" = "Done";
"Enter an optional title..." = "Enter an optional title...";
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
"Error" = "Error";
"Error: %@" = "Error: %@";
"Favorite" = "Favorite";
"Finished reading?" = "Finished reading?";
"Font" = "Font";
"Font family" = "Font family";
"Font Settings" = "Font Settings";
"Font size" = "Font size";
"From Bremen with 💚" = "From Bremen with 💚";
"General" = "General";
"Global Level" = "Global Level";
"Global Minimum Level" = "Global Minimum Level";
"Global Settings" = "Global Settings";
"https://example.com" = "https://example.com";
"https://readeck.example.com" = "https://readeck.example.com";
"Include Source Location" = "Include Source Location";
"Info" = "Info";
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
"Key" = "Key";
"Level for %@" = "Level for %@";
"Loading %@" = "Loading %@";
"Loading article..." = "Loading article...";
"Logging Configuration" = "Logging Configuration";
"Login & Save" = "Login & Save";
"Logout" = "Logout";
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
"Manage Labels" = "Manage Labels";
"Mark as favorite" = "Mark as favorite";
"More" = "More";
"New Bookmark" = "New Bookmark";
"No articles in the queue" = "No articles in the queue";
"No bookmarks" = "No bookmarks";
"No bookmarks found in %@." = "No bookmarks found in %@.";
"No bookmarks found." = "No bookmarks found.";
"No results" = "No results";
"Notice" = "Notice";
"OK" = "OK";
"Optional: Custom title" = "Optional: Custom title";
"Password" = "Password";
"Paste" = "Paste";
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
"Preview" = "Preview";
"Progress: %lld%%" = "Progress: %lld%%";
"Re-login & Save" = "Re-login & Save";
"Read Aloud Feature" = "Read Aloud Feature";
"Read article aloud" = "Read article aloud";
"Read-aloud Queue" = "Read-aloud Queue";
"readeck Bookmark Title" = "readeck Bookmark Title";
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
"Remove" = "Remove";
"Reset" = "Reset";
"Reset to Defaults" = "Reset to Defaults";
"Restore" = "Restore";
"Resume listening" = "Resume listening";
"Save bookmark" = "Save bookmark";
"Save Bookmark" = "Save Bookmark";
"Saving..." = "Saving...";
"Search" = "Search";
"Search or add new tag..." = "Search or add new tag...";
"Search results" = "Search results";
"Search..." = "Search...";
"Searching..." = "Searching...";
"Select a bookmark or tag" = "Select a bookmark or tag";
"Selected tags" = "Selected tags";
"Server Endpoint" = "Server Endpoint";
"Server not reachable - saving locally" = "Server not reachable - saving locally";
"Settings" = "Settings";
"Show Performance Logs" = "Show Performance Logs";
"Show Timestamps" = "Show Timestamps";
"Speed" = "Speed";
"Syncing with server..." = "Syncing with server...";
"Theme" = "Theme";
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
"Try Again" = "Try Again";
"Unable to load bookmarks" = "Unable to load bookmarks";
"Unarchive Bookmark" = "Unarchive Bookmark";
"URL in clipboard:" = "URL in clipboard:";
"Username" = "Username";
"Version %@" = "Version %@";
"Warning" = "Warning";
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
"Your Password" = "Your Password";
"Your Username" = "Your Username";

View File

@ -0,0 +1,165 @@
/*
Localizable.strings (German)
readeck
Created by conversion from Localizable.xcstrings
*/
"" = "";
"(%lld found)" = "(%lld gefunden)";
"%" = "%";
"%@ (%lld)" = "%1$@ (%2$lld)";
"%lld" = "%lld";
"%lld articles in the queue" = "%lld Artikel in der Warteschlange";
"%lld bookmark%@ synced successfully" = "%1$lld Lesezeichen%2$@ erfolgreich synchronisiert";
"%lld bookmark%@ waiting for sync" = "%1$lld Lesezeichen%2$@ warten auf Synchronisation";
"%lld min" = "%lld Min";
"%lld." = "%lld.";
"%lld/%lld" = "%1$lld/%2$lld";
"12 min • Today • example.com" = "12 Min • Heute • example.com";
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Aktiviere die Vorlese-Funktion, um deine Artikel vorlesen zu lassen. Dies ist eine sehr frühe Vorschau und funktioniert möglicherweise noch nicht perfekt.";
/* Legal & Privacy */
"Legal & Privacy" = "Rechtliches & Datenschutz";
"Privacy Policy" = "Datenschutzerklärung";
"Legal Notice" = "Impressum";
"Report an Issue" = "Problem melden";
"Contact Support" = "Support kontaktieren";
/* Navigation & States */
"All" = "Alle";
"Unread" = "Ungelesen";
"Favorites" = "Favoriten";
"Archive" = "Archiv";
"Search" = "Suchen";
"Settings" = "Einstellungen";
"Articles" = "Artikel";
"Videos" = "Videos";
"Pictures" = "Bilder";
"Tags" = "Labels";
/* Settings Sections */
"Font Settings" = "Schriftart";
"Appearance" = "Darstellung";
"Cache Settings" = "Cache";
"General Settings" = "Allgemein";
"Server Settings" = "Server";
"Server Connection" = "Server-Verbindung";
"Open external links in" = "Öffne externe Links in";
"In App Browser" = "In App Browser";
"Default Browser" = "Standard Browser";
"Add" = "Hinzufügen";
"Add new tag:" = "Neues Label hinzufügen:";
"all" = "alle";
"All tags selected" = "Alle Labels ausgewählt";
"Archive" = "Archivieren";
"Archive bookmark" = "Lesezeichen archivieren";
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Dieses Lesezeichen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Wirklich abmelden? Dies löscht alle Anmeldedaten und führt zurück zur Einrichtung.";
"Available tags" = "Verfügbare Labels";
"Most used tags" = "Meist verwendete Labels";
"Sorted by usage count" = "Sortiert nach Verwendungshäufigkeit";
"Sorted alphabetically" = "Alphabetisch sortiert";
"Cancel" = "Abbrechen";
"Category-specific Levels" = "Kategorie-spezifische Level";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Änderungen werden sofort wirksam. Niedrigere Log-Level enthalten höhere (Debug enthält alle, Critical nur kritische Nachrichten).";
"Close" = "Schließen";
"Configure log levels and categories" = "Log-Level und Kategorien konfigurieren";
"Critical" = "Kritisch";
"Debug" = "Debug";
"DEBUG BUILD" = "DEBUG BUILD";
"Debug Settings" = "Debug";
"Delete" = "Löschen";
"Delete Bookmark" = "Lesezeichen löschen";
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
"Done" = "Fertig";
"Enter an optional title..." = "Optionalen Titel eingeben...";
"Enter your Readeck server details to get started." = "Readeck-Server-Details eingeben, um zu beginnen.";
"Error" = "Fehler";
"Error: %@" = "Fehler: %@";
"Favorite" = "Favorit";
"Finished reading?" = "Fertig gelesen?";
"Font" = "Schrift";
"Font family" = "Schriftart";
"Font Settings" = "Schrift";
"Font size" = "Schriftgröße";
"From Bremen with 💚" = "Aus Bremen mit 💚";
"General" = "Allgemein";
"Global Level" = "Globales Level";
"Global Minimum Level" = "Globales Minimum-Level";
"Global Settings" = "Global";
"https://example.com" = "https://example.com";
"https://readeck.example.com" = "https://readeck.example.com";
"Include Source Location" = "Quellort einschließen";
"Info" = "Info";
"Jump to last read position (%lld%%)" = "Zur letzten Leseposition springen (%lld%%)";
"Key" = "Schlüssel";
"Level for %@" = "Level für %@";
"Loading %@" = "Lade %@";
"Loading article..." = "Artikel wird geladen...";
"Logging Configuration" = "Logging-Konfiguration";
"Login & Save" = "Anmelden & Speichern";
"Logout" = "Abmelden";
"Logs below this level will be filtered out globally" = "Logs unter diesem Level werden global herausgefiltert";
"Manage Labels" = "Labels verwalten";
"Mark as favorite" = "Als Favorit markieren";
"More" = "Mehr";
"New Bookmark" = "Neues Lesezeichen";
"No articles in the queue" = "Keine Artikel in der Warteschlange";
"open_url" = "%@ öffnen";
"open_original_page" = "Originalseite öffnen";
"No bookmarks" = "Keine Lesezeichen";
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
"No bookmarks found." = "Keine Lesezeichen gefunden.";
"No results" = "Keine Ergebnisse";
"Notice" = "Hinweis";
"OK" = "OK";
"Optional: Custom title" = "Optional: Benutzerdefinierter Titel";
"Password" = "Passwort";
"Paste" = "Einfügen";
"Please wait while we fetch your bookmarks..." = "Bitte warten, während die Lesezeichen geladen werden...";
"Preview" = "Vorschau";
"Progress: %lld%%" = "Fortschritt: %lld%%";
"Re-login & Save" = "Erneut anmelden & Speichern";
"Read Aloud Feature" = "Vorlese-Funktion";
"Read article aloud" = "Artikel vorlesen";
"Read-aloud Queue" = "Vorlese-Warteschlange";
"readeck Bookmark Title" = "readeck Lesezeichen-Titel";
"Reading %lld/%lld: " = "Lese %1$lld/%2$lld: ";
"Remove" = "Entfernen";
"Reset" = "Zurücksetzen";
"Reset to Defaults" = "Auf Standardwerte zurücksetzen";
"Restore" = "Wiederherstellen";
"Resume listening" = "Zuhören fortsetzen";
"Save bookmark" = "Lesezeichen speichern";
"Save Bookmark" = "Lesezeichen speichern";
"Saving..." = "Speichern...";
"Search" = "Suchen";
"Search or add new tag..." = "Suchen oder neues Label hinzufügen...";
"Search results" = "Suchergebnisse";
"Search..." = "Suchen...";
"Searching..." = "Suche...";
"Select a bookmark or tag" = "Lesezeichen oder Label auswählen";
"Selected tags" = "Ausgewählte Labels";
"Server Endpoint" = "Server-Endpunkt";
"Server not reachable - saving locally" = "Server nicht erreichbar - speichere lokal";
"Settings" = "Einstellungen";
"Show Performance Logs" = "Performance-Logs anzeigen";
"Show Timestamps" = "Zeitstempel anzeigen";
"Speed" = "Geschwindigkeit";
"Syncing with server..." = "Synchronisiere mit Server...";
"Theme" = "Design";
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "So werden Lesezeichen-Beschreibungen und Artikeltexte in der App angezeigt. Franz jagt im komplett verwahrlosten Taxi quer durch Bayern.";
"Try Again" = "Erneut versuchen";
"Unable to load bookmarks" = "Lesezeichen können nicht geladen werden";
"Unarchive Bookmark" = "Lesezeichen aus Archiv entfernen";
"URL in clipboard:" = "URL in Zwischenablage:";
"Username" = "Benutzername";
"Version %@" = "Version %@";
"Warning" = "Warnung";
"Your current server connection and login credentials." = "Aktuelle Serververbindung und Anmeldedaten.";
"Your Password" = "Passwort";
"Your Username" = "Benutzername";

View File

@ -0,0 +1,160 @@
/*
Localizable.strings
readeck
Created by conversion from Localizable.xcstrings
*/
"" = "";
"(%lld found)" = "(%lld found)";
"%" = "%";
"%@ (%lld)" = "%1$@ (%2$lld)";
"%lld" = "%lld";
"%lld articles in the queue" = "%lld articles in the queue";
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
"%lld min" = "%lld min";
"%lld." = "%lld.";
"%lld/%lld" = "%1$lld/%2$lld";
"12 min • Today • example.com" = "12 min • Today • example.com";
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
/* Legal & Privacy */
"Legal & Privacy" = "Legal & Privacy";
"Privacy Policy" = "Privacy Policy";
"Legal Notice" = "Legal Notice";
"Report an Issue" = "Report an Issue";
"Contact Support" = "Contact Support";
/* Navigation & States */
"All" = "All";
"Unread" = "Unread";
"Favorites" = "Favorites";
"Archive" = "Archive";
"Search" = "Search";
"Settings" = "Settings";
"Articles" = "Articles";
"Videos" = "Videos";
"Pictures" = "Pictures";
"Tags" = "Tags";
/* Settings Sections */
"Font Settings" = "Font Settings";
"Appearance" = "Appearance";
"Cache Settings" = "Cache Settings";
"General Settings" = "General Settings";
"Server Settings" = "Server Settings";
"Server Connection" = "Server Connection";
"Add" = "Add";
"Add new tag:" = "Add new tag:";
"all" = "all";
"All tags selected" = "All tags selected";
"Archive" = "Archive";
"Archive bookmark" = "Archive bookmark";
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
"Available tags" = "Available tags";
"Most used tags" = "Most used tags";
"Sorted by usage count" = "Sorted by usage count";
"Sorted alphabetically" = "Sorted alphabetically";
"Cancel" = "Cancel";
"Category-specific Levels" = "Category-specific Levels";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
"Close" = "Close";
"Configure log levels and categories" = "Configure log levels and categories";
"Critical" = "Critical";
"Debug" = "Debug";
"DEBUG BUILD" = "DEBUG BUILD";
"Debug Settings" = "Debug Settings";
"Delete" = "Delete";
"Delete Bookmark" = "Delete Bookmark";
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
"Done" = "Done";
"Enter an optional title..." = "Enter an optional title...";
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
"Error" = "Error";
"Error: %@" = "Error: %@";
"Favorite" = "Favorite";
"Finished reading?" = "Finished reading?";
"Font" = "Font";
"Font family" = "Font family";
"Font Settings" = "Font Settings";
"Font size" = "Font size";
"From Bremen with 💚" = "From Bremen with 💚";
"General" = "General";
"Global Level" = "Global Level";
"Global Minimum Level" = "Global Minimum Level";
"Global Settings" = "Global Settings";
"https://example.com" = "https://example.com";
"https://readeck.example.com" = "https://readeck.example.com";
"Include Source Location" = "Include Source Location";
"Info" = "Info";
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
"Key" = "Key";
"Level for %@" = "Level for %@";
"Loading %@" = "Loading %@";
"Loading article..." = "Loading article...";
"Logging Configuration" = "Logging Configuration";
"Login & Save" = "Login & Save";
"Logout" = "Logout";
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
"Manage Labels" = "Manage Labels";
"Mark as favorite" = "Mark as favorite";
"More" = "More";
"New Bookmark" = "New Bookmark";
"No articles in the queue" = "No articles in the queue";
"open_url" = "Open %@";
"open_original_page" = "Open original page";
"No bookmarks" = "No bookmarks";
"No bookmarks found in %@." = "No bookmarks found in %@.";
"No bookmarks found." = "No bookmarks found.";
"No results" = "No results";
"Notice" = "Notice";
"OK" = "OK";
"Optional: Custom title" = "Optional: Custom title";
"Password" = "Password";
"Paste" = "Paste";
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
"Preview" = "Preview";
"Progress: %lld%%" = "Progress: %lld%%";
"Re-login & Save" = "Re-login & Save";
"Read Aloud Feature" = "Read Aloud Feature";
"Read article aloud" = "Read article aloud";
"Read-aloud Queue" = "Read-aloud Queue";
"readeck Bookmark Title" = "readeck Bookmark Title";
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
"Remove" = "Remove";
"Reset" = "Reset";
"Reset to Defaults" = "Reset to Defaults";
"Restore" = "Restore";
"Resume listening" = "Resume listening";
"Save bookmark" = "Save bookmark";
"Save Bookmark" = "Save Bookmark";
"Saving..." = "Saving...";
"Search" = "Search";
"Search or add new tag..." = "Search or add new tag...";
"Search results" = "Search results";
"Search..." = "Search...";
"Searching..." = "Searching...";
"Select a bookmark or tag" = "Select a bookmark or tag";
"Selected tags" = "Selected tags";
"Server Endpoint" = "Server Endpoint";
"Server not reachable - saving locally" = "Server not reachable - saving locally";
"Settings" = "Settings";
"Show Performance Logs" = "Show Performance Logs";
"Show Timestamps" = "Show Timestamps";
"Speed" = "Speed";
"Syncing with server..." = "Syncing with server...";
"Theme" = "Theme";
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
"Try Again" = "Try Again";
"Unable to load bookmarks" = "Unable to load bookmarks";
"Unarchive Bookmark" = "Unarchive Bookmark";
"URL in clipboard:" = "URL in clipboard:";
"Username" = "Username";
"Version %@" = "Version %@";
"Warning" = "Warning";
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
"Your Password" = "Your Password";
"Your Username" = "Your Username";

View File

@ -4,6 +4,8 @@ import UIKit
struct AddBookmarkView: View {
@State private var viewModel = AddBookmarkViewModel()
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject private var appSettings: AppSettings
@FocusState private var focusedField: AddBookmarkFieldFocus?
@State private var keyboardHeight: CGFloat = 0
@ -58,9 +60,9 @@ struct AddBookmarkView: View {
}
.onAppear {
viewModel.checkClipboard()
}
.task {
await viewModel.loadAllLabels()
Task {
await viewModel.syncTags()
}
}
.onDisappear {
viewModel.clearForm()
@ -74,11 +76,9 @@ struct AddBookmarkView: View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 20) {
VStack(spacing: 20) {
VStack(spacing: 16) {
urlField
.id("urlField")
Spacer()
.frame(height: 40)
.id("labelsOffset")
labelsField
.id("labelsField")
@ -160,10 +160,11 @@ struct AddBookmarkView: View {
}
}
}
.padding()
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 8))
.transition(.opacity.combined(with: .move(edge: .top)))
.padding(.top, 4)
}
}
@ -178,24 +179,29 @@ struct AddBookmarkView: View {
@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)
}
)
VStack(alignment: .leading, spacing: 8) {
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
.font(.caption)
.foregroundColor(.secondary)
CoreDataTagManagementView(
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
searchFieldFocus: $focusedField,
fetchLimit: nil,
sortOrder: appSettings.tagSortOrder,
context: viewContext,
onAddCustomTag: {
viewModel.addCustomTag()
},
onToggleLabel: { label in
viewModel.toggleLabel(label)
},
onRemoveLabel: { label in
viewModel.removeLabel(label)
}
)
}
}
@ViewBuilder

View File

@ -8,6 +8,8 @@ class AddBookmarkViewModel {
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
private let createLabelUseCase = DefaultUseCaseFactory.shared.makeCreateLabelUseCase()
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
// MARK: - Form Data
var url: String = ""
@ -59,26 +61,20 @@ class AddBookmarkViewModel {
}
}
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
/// Triggers background sync of tags from server to Core Data
/// CoreDataTagManagementView will automatically update via @FetchRequest
@MainActor
func syncTags() async {
try? await syncTagsUseCase.execute()
}
@MainActor
func loadAllLabels() async {
isLabelsLoading = true
defer { isLabelsLoading = false }
do {
let labels = try await getLabelsUseCase.execute()
allLabels = labels.sorted { $0.count > $1.count }
@ -92,17 +88,22 @@ class AddBookmarkViewModel {
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 = ""
// Save new label to Core Data so it's available next time
Task {
try? await createLabelUseCase.execute(name: trimmed)
}
}
}

View File

@ -0,0 +1,98 @@
//
// AppViewModel.swift
// readeck
//
// Created by Ilyas Hallak on 27.08.25.
//
import Foundation
import SwiftUI
@MainActor
@Observable
class AppViewModel {
private let settingsRepository = SettingsRepository()
private let factory: UseCaseFactory
private let syncTagsUseCase: PSyncTagsUseCase
var hasFinishedSetup: Bool = true
var isServerReachable: Bool = false
private var lastAppStartTagSyncTime: Date?
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.factory = factory
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
setupNotificationObservers()
loadSetupStatus()
}
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(
forName: .unauthorizedAPIResponse,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
await self?.handleUnauthorizedResponse()
}
}
NotificationCenter.default.addObserver(
forName: .setupStatusChanged,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.loadSetupStatus()
}
}
}
private func handleUnauthorizedResponse() async {
print("AppViewModel: Handling 401 Unauthorized - logging out user")
do {
try await factory.makeLogoutUseCase().execute()
loadSetupStatus()
print("AppViewModel: User successfully logged out due to 401 error")
} catch {
print("AppViewModel: Error during logout: \(error)")
}
}
private func loadSetupStatus() {
hasFinishedSetup = settingsRepository.hasFinishedSetup
}
func onAppResume() async {
await checkServerReachability()
await syncTagsOnAppStart()
}
private func checkServerReachability() async {
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
}
private func syncTagsOnAppStart() async {
let now = Date()
// Check if last sync was less than 2 minutes ago
if let lastSync = lastAppStartTagSyncTime,
now.timeIntervalSince(lastSync) < 120 {
print("AppViewModel: Skipping tag sync - last sync was less than 2 minutes ago")
return
}
// Sync tags from server to Core Data
print("AppViewModel: Syncing tags on app start")
try? await syncTagsUseCase.execute()
lastAppStartTagSyncTime = now
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}

View File

@ -0,0 +1,45 @@
import SwiftUI
struct AnnotationColorOverlay: View {
let onColorSelected: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 8) {
ForEach(Constants.annotationColors, id: \.self) { color in
ColorButton(color: color, onTap: onColorSelected)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2)
)
}
private struct ColorButton: View {
let color: AnnotationColor
let onTap: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: { onTap(color) }) {
Circle()
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
.frame(width: 36, height: 36)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
)
}
}
}
}
#Preview {
AnnotationColorOverlay { color in
print("Selected: \(color)")
}
.padding()
}

View File

@ -0,0 +1,63 @@
import SwiftUI
struct AnnotationColorPicker: View {
let selectedText: String
let onColorSelected: (AnnotationColor) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text("Highlight Text")
.font(.headline)
Text(selectedText)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(3)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
Text("Select Color")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 16) {
ForEach(Constants.annotationColors, id: \.self) { color in
ColorButton(color: color, onTap: handleColorSelection)
}
}
Button("Cancel") {
dismiss()
}
.foregroundColor(.secondary)
}
.padding(24)
.frame(maxWidth: 400)
}
private func handleColorSelection(_ color: AnnotationColor) {
onColorSelected(color)
dismiss()
}
}
struct ColorButton: View {
let color: AnnotationColor
let onTap: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: { onTap(color) }) {
Circle()
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.2), lineWidth: 1)
)
}
}
}

View File

@ -0,0 +1,131 @@
import SwiftUI
struct AnnotationsListView: View {
let bookmarkId: String
@State private var viewModel = AnnotationsListViewModel()
@Environment(\.dismiss) private var dismiss
var onAnnotationTap: ((String) -> Void)?
enum ViewState {
case loading
case empty
case loaded([Annotation])
case error(String)
}
private var viewState: ViewState {
if viewModel.isLoading {
return .loading
} else if let error = viewModel.errorMessage, viewModel.showErrorAlert {
return .error(error)
} else if viewModel.annotations.isEmpty {
return .empty
} else {
return .loaded(viewModel.annotations)
}
}
var body: some View {
List {
switch viewState {
case .loading:
HStack {
Spacer()
ProgressView()
Spacer()
}
case .empty:
ContentUnavailableView(
"No Annotations",
systemImage: "pencil.slash",
description: Text("This bookmark has no annotations yet.")
)
case .loaded(let annotations):
ForEach(annotations) { annotation in
Button(action: {
onAnnotationTap?(annotation.id)
dismiss()
}) {
VStack(alignment: .leading, spacing: 8) {
if !annotation.text.isEmpty {
Text(annotation.text)
.font(.body)
.foregroundColor(.primary)
}
Text(formatDate(annotation.created))
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task {
await viewModel.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotation.id)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
case .error:
EmptyView()
}
}
.navigationTitle("Annotations")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
}
}
}
.task {
await viewModel.loadAnnotations(for: bookmarkId)
}
.alert("Error", isPresented: $viewModel.showErrorAlert) {
Button("OK", role: .cancel) {}
} message: {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
}
}
}
private func formatDate(_ dateString: String) -> String {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoFormatterNoMillis = ISO8601DateFormatter()
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
var date: Date?
if let parsedDate = isoFormatter.date(from: dateString) {
date = parsedDate
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
date = parsedDate
}
if let date = date {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
displayFormatter.timeStyle = .short
displayFormatter.locale = .autoupdatingCurrent
return displayFormatter.string(from: date)
}
return dateString
}
}
#Preview {
NavigationStack {
AnnotationsListView(bookmarkId: "123")
}
}

View File

@ -0,0 +1,42 @@
import Foundation
@Observable
class AnnotationsListViewModel {
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
private let deleteAnnotationUseCase: PDeleteAnnotationUseCase
var annotations: [Annotation] = []
var isLoading = false
var errorMessage: String?
var showErrorAlert = false
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
self.deleteAnnotationUseCase = factory.makeDeleteAnnotationUseCase()
}
@MainActor
func loadAnnotations(for bookmarkId: String) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
annotations = try await getAnnotationsUseCase.execute(bookmarkId: bookmarkId)
} catch {
errorMessage = "Failed to load annotations"
showErrorAlert = true
}
}
@MainActor
func deleteAnnotation(bookmarkId: String, annotationId: String) async {
do {
try await deleteAnnotationUseCase.execute(bookmarkId: bookmarkId, annotationId: annotationId)
annotations.removeAll { $0.id == annotationId }
} catch {
errorMessage = "Failed to delete annotation"
showErrorAlert = true
}
}
}

View File

@ -0,0 +1,598 @@
import SwiftUI
import SafariServices
// PreferenceKey for scroll offset tracking
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
value = nextValue()
}
}
// PreferenceKey for content height tracking
struct ContentHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct BookmarkDetailLegacyView: View {
let bookmarkId: String
@Binding var useNativeWebView: Bool
// MARK: - States
@State private var viewModel: BookmarkDetailViewModel
@State private var webViewHeight: CGFloat = 300
@State private var contentEndPosition: CGFloat = 0
@State private var initialContentEndPosition: CGFloat = 0
@State private var showingFontSettings = false
@State private var showingLabelsSheet = false
@State private var showingAnnotationsSheet = false
@State private var readingProgress: Double = 0.0
@State private var lastSentProgress: Double = 0.0
@State private var showJumpToProgressButton: Bool = false
@State private var scrollPosition = ScrollPosition(edge: .top)
@State private var showingImageViewer = false
// MARK: - Envs
@EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
@Environment(\.dismiss) private var dismiss
private let headerHeight: CGFloat = 360
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
self.bookmarkId = bookmarkId
self._useNativeWebView = useNativeWebView
self.viewModel = viewModel
}
var body: some View {
VStack(spacing: 0) {
ProgressView(value: readingProgress)
.progressViewStyle(LinearProgressViewStyle())
.frame(height: 3)
GeometryReader { geometry in
ScrollView {
// Invisible GeometryReader to track scroll offset
GeometryReader { scrollGeo in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: CGPoint(
x: scrollGeo.frame(in: .named("scrollView")).minX,
y: scrollGeo.frame(in: .named("scrollView")).minY
)
)
}
.frame(height: 0)
VStack(spacing: 0) {
ZStack(alignment: .top) {
headerView(width: geometry.size.width)
VStack(alignment: .leading, spacing: 16) {
Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
titleSection
Divider().padding(.horizontal)
if showJumpToProgressButton {
JumpButton(containerHeight: geometry.size.height)
}
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(
htmlContent: viewModel.articleContent,
settings: settings,
onHeightChange: { height in
if webViewHeight != height {
webViewHeight = height
}
},
selectedAnnotationId: viewModel.selectedAnnotationId,
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
Task {
await viewModel.createAnnotation(
bookmarkId: bookmarkId,
color: color,
text: text,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
},
onScrollToPosition: { position in
// Calculate scroll position: add header height and webview offset
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
let targetPosition = imageHeight + position
// Scroll to the annotation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollPosition = ScrollPosition(y: targetPosition)
}
}
)
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal, 4)
} else if viewModel.isLoadingArticle {
ProgressView("Loading article...")
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) {
HStack {
Image(systemName: "safari")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.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)
}
}
.frame(maxWidth: .infinity)
}
// Invisible marker to measure total content height - placed AFTER all content
Color.clear
.frame(height: 1)
.background(
GeometryReader { endGeo in
Color.clear.preference(
key: ContentHeightPreferenceKey.self,
value: endGeo.frame(in: .named("scrollView")).maxY
)
}
)
}
}
.coordinateSpace(name: "scrollView")
.clipped()
.ignoresSafeArea(edges: .top)
.scrollPosition($scrollPosition)
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
contentEndPosition = endPosition
let containerHeight = geometry.size.height
// Update initial position if content grows (WebView still loading) or first time
// We always take the maximum position seen (when scrolled to top, this is total content height)
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
initialContentEndPosition = endPosition
print("📏 Content end position updated: \(Int(endPosition)) (container: \(Int(containerHeight)))")
}
// Calculate progress from how much the end marker has moved up
guard initialContentEndPosition > 0 else {
print("⏳ Waiting for content to load... current: \(Int(endPosition)), container: \(Int(containerHeight))")
return
}
let totalScrollableDistance = initialContentEndPosition - containerHeight
guard totalScrollableDistance > 0 else {
print("⚠️ Content not scrollable: initial=\(initialContentEndPosition), container=\(containerHeight)")
return
}
// How far has the marker moved from its initial position?
let scrolled = initialContentEndPosition - endPosition
let rawProgress = scrolled / totalScrollableDistance
var progress = min(max(rawProgress, 0), 1)
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
if lastSentProgress >= 0.995 {
progress = max(progress, 1.0)
}
print("📊 Progress: \(Int(progress * 100))% | scrolled: \(Int(scrolled)) / \(Int(totalScrollableDistance)) | endPos: \(Int(endPosition))")
// Check if we should update: threshold OR reaching 100% for first time
let threshold: Double = 0.03
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
if shouldUpdate {
print("✅ Updating progress: \(Int(lastSentProgress * 100))% → \(Int(progress * 100))%\(reachedEnd ? " [END]" : "")")
lastSentProgress = progress
readingProgress = progress
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
// Not needed anymore, we track via ContentHeightPreferenceKey
}
}
}
.frame(maxWidth: .infinity)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
#if DEBUG
// Toggle button (left)
ToolbarItem(placement: .navigationBarLeading) {
if #available(iOS 26.0, *) {
Button(action: {
useNativeWebView.toggle()
}) {
Image(systemName: "waveform")
.foregroundColor(.accentColor)
}
}
}
#endif
// Top toolbar (right)
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
Button(action: {
showingLabelsSheet = true
}) {
Image(systemName: "tag")
}
Button(action: {
showingAnnotationsSheet = true
}) {
Image(systemName: "pencil.line")
}
Button(action: {
showingFontSettings = true
}) {
Image(systemName: "textformat")
}
}
}
}
.sheet(isPresented: $showingFontSettings) {
NavigationView {
VStack {
FontSettingsView()
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
}
.navigationTitle("Font Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
showingFontSettings = false
}
}
}
}
}
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.sheet(isPresented: $showingAnnotationsSheet) {
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
viewModel.selectedAnnotationId = annotationId
}
}
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
// Reload settings when sheet is dismissed
Task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: showingLabelsSheet) { _, isShowing in
if !isShowing {
// Reload bookmark detail when labels sheet is dismissed
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: showingAnnotationsSheet) { _, isShowing in
if !isShowing {
// Reload bookmark detail when labels sheet is dismissed
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
// Trigger WebView reload when annotation is selected
}
.task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)
}
}
// MARK: - ViewBuilder
@ViewBuilder
private func headerView(width: CGFloat) -> some View {
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
ZStack(alignment: .bottomTrailing) {
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fill)
.frame(width: width, height: headerHeight)
.clipped()
// Zoom icon
Button(action: {
showingImageViewer = true
}) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.padding(8)
.background(
Circle()
.fill(Color.black.opacity(0.6))
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
)
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
.frame(height: headerHeight)
.ignoresSafeArea(edges: .top)
.onTapGesture {
showingImageViewer = true
}
}
}
private var titleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text(viewModel.bookmarkDetail.title)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
.padding(.bottom, 2)
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
metaInfoSection
}
.padding(.horizontal)
}
@ViewBuilder
private var contentSection: some View {
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
withAnimation(.easeInOut(duration: 0.1)) {
webViewHeight = height
}
}
.frame(maxWidth: .infinity)
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal, 4)
.animation(.easeInOut, value: webViewHeight)
} else if viewModel.isLoadingArticle {
ProgressView("Loading article...")
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) {
HStack {
Image(systemName: "safari")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal)
.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 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
}
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
// Labels section
if !viewModel.bookmarkDetail.labels.isEmpty {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "tag")
.foregroundColor(.secondary)
.padding(.top, 2)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.accentColor.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
)
)
}
}
.padding(.trailing, 8)
}
}
}
metaRow(icon: "safari") {
Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) {
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
.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)
}
}
}
}
}
@ViewBuilder
private func metaRow(icon: String, text: String) -> some View {
HStack {
Image(systemName: icon)
Text(text)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
@ViewBuilder
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
HStack {
Image(systemName: icon)
content()
}
}
private func formatDate(_ dateString: String) -> String {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoFormatterNoMillis = ISO8601DateFormatter()
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
var date: Date?
if let parsedDate = isoFormatter.date(from: dateString) {
date = parsedDate
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
date = parsedDate
}
if let date = date {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
displayFormatter.timeStyle = .short
displayFormatter.locale = .autoupdatingCurrent
return displayFormatter.string(from: date)
}
return dateString
}
private var archiveSection: some View {
VStack(alignment: .center, spacing: 12) {
Text("Finished reading?")
.font(.headline)
.padding(.top, 24)
VStack(alignment: .center, spacing: 16) {
Button(action: {
Task {
await viewModel.toggleFavorite(id: bookmarkId)
}
}) {
HStack {
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(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)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.footnote)
}
}
.padding(.horizontal)
.padding(.bottom, 32)
}
@ViewBuilder
func JumpButton(containerHeight: CGFloat) -> some View {
Button(action: {
let maxOffset = webViewHeight - containerHeight
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])
}
}
#Preview {
NavigationView {
BookmarkDetailLegacyView(
bookmarkId: "123",
useNativeWebView: .constant(false),
viewModel: .init(MockUseCaseFactory())
)
}
}

View File

@ -1,503 +1,30 @@
import SwiftUI
import SafariServices
import Combine
/// Container view that routes to the appropriate BookmarkDetail implementation
/// based on iOS version availability or user preference
struct BookmarkDetailView: View {
let bookmarkId: String
let namespace: Namespace.ID?
// 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)
@State private var showingImageViewer = false
// MARK: - Envs
@EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
@Environment(\.dismiss) private var dismiss
private let headerHeight: CGFloat = 320
init(bookmarkId: String, namespace: Namespace.ID? = nil, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId
self.namespace = namespace
self.viewModel = viewModel
self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings
self.showingLabelsSheet = showingLabelsSheet
}
var body: some View {
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)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
Button(action: {
showingLabelsSheet = true
}) {
Image(systemName: "tag")
}
Button(action: {
showingFontSettings = true
}) {
Image(systemName: "textformat")
}
}
}
}
.sheet(isPresented: $showingFontSettings) {
NavigationView {
VStack {
FontSettingsView()
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
}
.navigationTitle("Font Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
showingFontSettings = false
}
}
}
}
}
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
// Reload settings when sheet is dismissed
Task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: showingLabelsSheet) { _, isShowing in
if !isShowing {
// Reload bookmark detail when labels sheet is dismissed
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)
}
}
// MARK: - ViewBuilder
@ViewBuilder
private func headerView(geometry: GeometryProxy) -> some View {
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
ZStack(alignment: .top) {
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
image
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
.clipped()
.offset(y: (offset > 0 ? -offset : 0))
.if(namespace != nil) { view in
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
}
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.4))
.frame(width: geometry.size.width, height: headerHeight)
.if(namespace != nil) { view in
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
}
}
// Gradient overlay für bessere Button-Sichtbarkeit
LinearGradient(
gradient: Gradient(colors: [
Color.black.opacity(1.0),
Color.black.opacity(0.9),
Color.black.opacity(0.7),
Color.black.opacity(0.4),
Color.black.opacity(0.2),
Color.clear
]),
startPoint: .top,
endPoint: .bottom
)
.frame(height: 240)
.frame(maxWidth: .infinity)
.offset(y: (offset > 0 ? -offset : 0))
// Tap area and zoom icon
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
showingImageViewer = true
}) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.padding(8)
.background(
Circle()
.fill(Color.black.opacity(0.6))
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
)
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
}
.frame(height: headerHeight + (offset > 0 ? offset : 0))
.offset(y: (offset > 0 ? -offset : 0))
}
}
.frame(height: headerHeight)
.ignoresSafeArea(edges: .top)
.onTapGesture {
showingImageViewer = true
}
}
}
private var titleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text(viewModel.bookmarkDetail.title)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
.padding(.bottom, 2)
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
metaInfoSection
}
.padding(.horizontal)
}
@ViewBuilder
private var contentSection: some View {
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
withAnimation(.easeInOut(duration: 0.1)) {
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)
}
}
private var metaInfoSection: some View {
VStack(alignment: .leading, spacing: 8) {
if !viewModel.bookmarkDetail.authors.isEmpty {
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) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
// Labels section
if !viewModel.bookmarkDetail.labels.isEmpty {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "tag")
.foregroundColor(.secondary)
.padding(.top, 2)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.accentColor.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
)
)
}
}
.padding(.trailing, 8)
}
}
}
metaRow(icon: "safari") {
Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
}) {
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
.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)
}
}
}
}
}
@ViewBuilder
private func metaRow(icon: String, text: String) -> some View {
HStack {
Image(systemName: icon)
Text(text)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
@ViewBuilder
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
HStack {
Image(systemName: icon)
content()
}
}
private func formatDate(_ dateString: String) -> String {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoFormatterNoMillis = ISO8601DateFormatter()
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
var date: Date?
if let parsedDate = isoFormatter.date(from: dateString) {
date = parsedDate
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
date = parsedDate
}
if let date = date {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
displayFormatter.timeStyle = .short
displayFormatter.locale = .autoupdatingCurrent
return displayFormatter.string(from: date)
}
return dateString
}
private var archiveSection: some View {
VStack(alignment: .center, spacing: 12) {
Text("Finished reading?")
.font(.headline)
.padding(.top, 24)
VStack(alignment: .center, spacing: 16) {
Button(action: {
Task {
await viewModel.toggleFavorite(id: bookmarkId)
}
}) {
HStack {
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(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)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.footnote)
}
}
.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()
@AppStorage("useNativeWebView") private var useNativeWebView: Bool = true
var body: some View {
if #available(iOS 26.0, *) {
if useNativeWebView {
// Use modern SwiftUI-native implementation on iOS 26+
BookmarkDetailView2(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView)
} else {
// Use legacy WKWebView-based implementation
BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView)
}
} else {
// iOS < 26: always use Legacy
BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: .constant(false))
}
}
}
#Preview {
NavigationView {
BookmarkDetailView(bookmarkId: "123",
viewModel: .init(MockUseCaseFactory()),
webViewHeight: 300,
showingFontSettings: false,
showingLabelsSheet: false,
playerUIState: .init())
BookmarkDetailView(bookmarkId: "123")
}
}

View File

@ -0,0 +1,566 @@
import SwiftUI
import SafariServices
@available(iOS 26.0, *)
struct BookmarkDetailView2: View {
let bookmarkId: String
@Binding var useNativeWebView: Bool
// MARK: - States
@State private var viewModel: BookmarkDetailViewModel
@State private var webViewHeight: CGFloat = 300
@State private var contentEndPosition: CGFloat = 0
@State private var initialContentEndPosition: CGFloat = 0
@State private var showingFontSettings = false
@State private var showingLabelsSheet = false
@State private var showingAnnotationsSheet = false
@State private var readingProgress: Double = 0.0
@State private var lastSentProgress: Double = 0.0
@State private var showJumpToProgressButton: Bool = false
@State private var scrollPosition = ScrollPosition(edge: .top)
@State private var showingImageViewer = false
// MARK: - Envs
@EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
@Environment(\.dismiss) private var dismiss
private let headerHeight: CGFloat = 360
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
self.bookmarkId = bookmarkId
self._useNativeWebView = useNativeWebView
self.viewModel = viewModel
}
var body: some View {
mainView
}
private var mainView: some View {
content
.navigationBarTitleDisplayMode(.inline)
.toolbar {
toolbarContent
}
.sheet(isPresented: $showingFontSettings) {
fontSettingsSheet
}
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.sheet(isPresented: $showingAnnotationsSheet) {
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
viewModel.selectedAnnotationId = annotationId
}
}
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
Task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: showingLabelsSheet) { _, isShowing in
if !isShowing {
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: showingAnnotationsSheet) { _, isShowing in
if !isShowing {
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
// Trigger WebView reload when annotation is selected
}
.task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)
}
}
private var content: some View {
VStack(spacing: 0) {
// Progress bar at top
ProgressView(value: readingProgress)
.progressViewStyle(LinearProgressViewStyle())
.frame(height: 3)
// Main scroll content
scrollViewContent
.overlay(alignment: .bottomTrailing) {
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
if readingProgress >= 0.9 {
floatingActionButtons
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: readingProgress >= 0.9)
}
}
private var floatingActionButtons: some View {
GlassEffectContainer(spacing: 52.0) {
HStack(spacing: 52.0) {
Button(action: {
Task {
await viewModel.toggleFavorite(id: bookmarkId)
}
}) {
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
.foregroundStyle(viewModel.bookmarkDetail.isMarked ? .yellow : .primary)
.frame(width: 52.0, height: 52.0)
.font(.system(size: 31))
}
.disabled(viewModel.isLoading)
.glassEffect()
Button(action: {
Task {
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
}
}) {
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
.frame(width: 52.0, height: 52.0)
.font(.system(size: 31))
}
.disabled(viewModel.isLoading)
.glassEffect()
.offset(x: -52.0, y: 0.0)
}
}
.padding(.trailing, 1)
.padding(.bottom, 10)
}
private var scrollViewContent: some View {
GeometryReader { geometry in
ScrollView {
// Invisible GeometryReader to track scroll offset
GeometryReader { scrollGeo in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: CGPoint(
x: scrollGeo.frame(in: .named("scrollView")).minX,
y: scrollGeo.frame(in: .named("scrollView")).minY
)
)
}
.frame(height: 0)
VStack(spacing: 0) {
ZStack(alignment: .top) {
headerView(width: geometry.size.width)
VStack(alignment: .leading, spacing: 16) {
Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
titleSection
Divider().padding(.horizontal)
if showJumpToProgressButton {
jumpButton(containerHeight: geometry.size.height)
}
// Article content (WebView)
articleContent
}
.frame(maxWidth: .infinity)
}
// Invisible marker to measure total content height - placed AFTER all content
Color.clear
.frame(height: 1)
.background(
GeometryReader { endGeo in
Color.clear.preference(
key: ContentHeightPreferenceKey.self,
value: endGeo.frame(in: .named("scrollView")).maxY
)
}
)
}
}
.coordinateSpace(name: "scrollView")
.clipped()
.ignoresSafeArea(edges: [.top, .bottom])
.scrollPosition($scrollPosition)
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
contentEndPosition = endPosition
let containerHeight = geometry.size.height
// Update initial position if content grows (WebView still loading) or first time
// We always take the maximum position seen (when scrolled to top, this is total content height)
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
initialContentEndPosition = endPosition
}
// Calculate progress from how much the end marker has moved up
guard initialContentEndPosition > 0 else { return }
let totalScrollableDistance = initialContentEndPosition - containerHeight
guard totalScrollableDistance > 0 else { return }
// How far has the marker moved from its initial position?
let scrolled = initialContentEndPosition - endPosition
let rawProgress = scrolled / totalScrollableDistance
var progress = min(max(rawProgress, 0), 1)
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
if lastSentProgress >= 0.995 {
progress = max(progress, 1.0)
}
// Check if we should update: threshold OR reaching 100% for first time
let threshold: Double = 0.03
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
readingProgress = progress
if shouldUpdate {
lastSentProgress = progress
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
// Not needed anymore, we track via ContentHeightPreferenceKey
}
}
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
#if DEBUG
// Toggle button (left)
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
useNativeWebView.toggle()
}) {
Image(systemName: "sparkles")
.foregroundColor(.accentColor)
}
}
#endif
// Top toolbar (right)
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
Button(action: {
showingLabelsSheet = true
}) {
Image(systemName: "tag")
}
if viewModel.hasAnnotations {
Button(action: {
showingAnnotationsSheet = true
}) {
Image(systemName: "pencil.line")
}
}
Button(action: {
showingFontSettings = true
}) {
Image(systemName: "textformat")
}
}
}
}
private var fontSettingsSheet: some View {
NavigationView {
VStack {
FontSettingsView()
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
}
.navigationTitle("Font Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
showingFontSettings = false
}
}
}
}
}
// MARK: - ViewBuilder
@ViewBuilder
private func headerView(width: CGFloat) -> some View {
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
ZStack(alignment: .bottomTrailing) {
// Background blur for images that don't fill
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fill)
.frame(width: width, height: headerHeight)
.blur(radius: 30)
.clipped()
// Main image with fit
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fit)
.frame(width: width, height: headerHeight)
// Zoom icon
Button(action: {
showingImageViewer = true
}) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.padding(8)
.background(
Circle()
.fill(Color.black.opacity(0.6))
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
)
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
.frame(width: width, height: headerHeight)
.ignoresSafeArea(edges: .top)
.onTapGesture {
showingImageViewer = true
}
}
}
private var titleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text(viewModel.bookmarkDetail.title)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
.padding(.bottom, 2)
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
metaInfoSection
}
.padding(.horizontal)
}
private var metaInfoSection: some View {
VStack(alignment: .leading, spacing: 8) {
if !viewModel.bookmarkDetail.authors.isEmpty {
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) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
// Labels section
if !viewModel.bookmarkDetail.labels.isEmpty {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "tag")
.foregroundColor(.secondary)
.padding(.top, 2)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.accentColor.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
)
)
}
}
.padding(.trailing, 8)
}
}
}
metaRow(icon: "safari") {
Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) {
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
.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)
}
}
}
}
}
@ViewBuilder
private func metaRow(icon: String, text: String) -> some View {
HStack {
Image(systemName: icon)
Text(text)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
@ViewBuilder
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
HStack {
Image(systemName: icon)
content()
}
}
@ViewBuilder
private var articleContent: some View {
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
if #available(iOS 26.0, *) {
NativeWebView(
htmlContent: viewModel.articleContent,
settings: settings,
onHeightChange: { height in
if webViewHeight != height {
webViewHeight = height
}
},
selectedAnnotationId: viewModel.selectedAnnotationId,
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
Task {
await viewModel.createAnnotation(
bookmarkId: bookmarkId,
color: color,
text: text,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
},
onScrollToPosition: { position in
// Calculate scroll position: add header height and webview offset
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
let targetPosition = imageHeight + position
// Scroll to the annotation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollPosition = ScrollPosition(y: targetPosition)
}
}
)
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal, 4)
}
} else if viewModel.isLoadingArticle {
ProgressView("Loading article...")
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) {
HStack {
Image(systemName: "safari")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal)
.padding(.top, 0)
}
}
private func jumpButton(containerHeight: CGFloat) -> some View {
Button(action: {
let maxOffset = webViewHeight - containerHeight
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])
}
private func formatDate(_ dateString: String) -> String {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoFormatterNoMillis = ISO8601DateFormatter()
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
var date: Date?
if let parsedDate = isoFormatter.date(from: dateString) {
date = parsedDate
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
date = parsedDate
}
if let date = date {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
displayFormatter.timeStyle = .short
displayFormatter.locale = .autoupdatingCurrent
return displayFormatter.string(from: date)
}
return dateString
}
}
#Preview {
if #available(iOS 26.0, *) {
NavigationView {
BookmarkDetailView2(
bookmarkId: "123",
useNativeWebView: .constant(true),
viewModel: .init(MockUseCaseFactory())
)
}
}
}

View File

@ -8,7 +8,8 @@ class BookmarkDetailViewModel {
private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
private let api: PAPI
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
var articleParagraphs: [String] = []
@ -18,7 +19,9 @@ class BookmarkDetailViewModel {
var errorMessage: String?
var settings: Settings?
var readProgress: Int = 0
var selectedAnnotationId: String?
var hasAnnotations: Bool = false
private var factory: UseCaseFactory?
private var cancellables = Set<AnyCancellable>()
private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>()
@ -28,8 +31,9 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.api = API()
self.factory = factory
readProgressSubject
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink { [weak self] (id, progress, anchor) in
@ -67,23 +71,26 @@ class BookmarkDetailViewModel {
@MainActor
func loadArticleContent(id: String) async {
isLoadingArticle = true
do {
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
processArticleContent()
} catch {
errorMessage = "Error loading article"
}
isLoadingArticle = false
}
private func processArticleContent() {
let paragraphs = articleContent
.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
articleParagraphs = paragraphs
// Check if article contains annotations
hasAnnotations = articleContent.contains("<rd-annotation")
}
@MainActor
@ -137,4 +144,22 @@ class BookmarkDetailViewModel {
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
readProgressSubject.send((id, progress, anchor))
}
@MainActor
func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async {
do {
let annotation = try await api.createAnnotation(
bookmarkId: bookmarkId,
color: color,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
print("✅ Annotation created: \(annotation.id)")
} catch {
print("❌ Failed to create annotation: \(error)")
errorMessage = "Error creating annotation"
}
}
}

View File

@ -4,6 +4,8 @@ struct BookmarkLabelsView: View {
let bookmarkId: String
@State private var viewModel: BookmarkLabelsViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject private var appSettings: AppSettings
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) {
self.bookmarkId = bookmarkId
@ -40,13 +42,15 @@ struct BookmarkLabelsView: View {
} message: {
Text(viewModel.errorMessage ?? "Unknown error")
}
.task {
await viewModel.loadAllLabels()
}
.ignoresSafeArea(.keyboard)
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
.onAppear {
Task {
await viewModel.syncTags()
}
}
}
}
@ -56,30 +60,36 @@ struct BookmarkLabelsView: View {
@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)
VStack(alignment: .leading, spacing: 8) {
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
CoreDataTagManagementView(
selectedLabels: Set(viewModel.currentLabels),
searchText: $viewModel.searchText,
fetchLimit: nil,
sortOrder: appSettings.tagSortOrder,
context: viewContext,
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)
}
}
},
onToggleLabel: { label in
Task {
await viewModel.toggleLabel(for: bookmarkId, label: label)
}
},
onRemoveLabel: { label in
Task {
await viewModel.removeLabel(from: bookmarkId, label: label)
}
}
)
.padding(.horizontal)
)
.padding(.horizontal)
}
}
}

View File

@ -5,60 +5,46 @@ class BookmarkLabelsViewModel {
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
private let getLabelsUseCase: PGetLabelsUseCase
private let syncTagsUseCase: PSyncTagsUseCase
var isLoading = false
var isInitialLoading = false
var errorMessage: String?
var showErrorAlert = false
var currentLabels: [String] = [] {
didSet {
if oldValue != currentLabels {
calculatePages()
}
}
}
var currentLabels: [String] = []
var newLabelText = ""
var searchText = "" {
didSet {
if oldValue != searchText {
calculatePages()
}
}
}
var searchText = ""
var allLabels: [BookmarkLabel] = [] {
didSet {
if oldValue != allLabels {
calculatePages()
}
}
}
var labelPages: [[BookmarkLabel]] = []
// Cached properties to avoid recomputation
private var _availableLabels: [BookmarkLabel] = []
private var _filteredLabels: [BookmarkLabel] = []
var allLabels: [BookmarkLabel] = []
var availableLabels: [BookmarkLabel] {
return _availableLabels
return allLabels.filter { !currentLabels.contains($0.name) }
}
var filteredLabels: [BookmarkLabel] {
return _filteredLabels
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()
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
}
/// Triggers background sync of tags from server to Core Data
/// CoreDataTagManagementView will automatically update via @FetchRequest
@MainActor
func syncTags() async {
try? await syncTagsUseCase.execute()
}
@MainActor
func loadAllLabels() async {
isInitialLoading = true
@ -70,8 +56,6 @@ class BookmarkLabelsViewModel {
errorMessage = "failed to load labels"
showErrorAlert = true
}
calculatePages()
}
@MainActor
@ -97,10 +81,12 @@ class BookmarkLabelsViewModel {
@MainActor
func addLabel(to bookmarkId: String, label: String) async {
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedLabel.isEmpty else { return }
let splitLabels = LabelUtils.splitLabelsFromInput(label)
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels)
await addLabels(to: bookmarkId, labels: [trimmedLabel])
guard !uniqueLabels.isEmpty else { return }
await addLabels(to: bookmarkId, labels: uniqueLabels)
newLabelText = ""
searchText = ""
}
@ -143,36 +129,4 @@ class BookmarkLabelsViewModel {
func updateLabels(_ labels: [String]) {
currentLabels = labels
}
private func calculatePages() {
let pageSize = Constants.Labels.pageSize
// Update cached available labels
_availableLabels = allLabels.filter { !currentLabels.contains($0.name) }
// Update cached filtered labels
if searchText.isEmpty {
_filteredLabels = _availableLabels
} else {
_filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
// Calculate pages for all labels
if allLabels.count <= pageSize {
labelPages = [allLabels]
} else {
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
}
}
// Calculate pages for filtered labels
if _filteredLabels.count <= pageSize {
availableLabelPages = [_filteredLabels]
} else {
availableLabelPages = stride(from: 0, to: _filteredLabels.count, by: pageSize).map {
Array(_filteredLabels[$0..<min($0 + pageSize, _filteredLabels.count)])
}
}
}
}

View File

@ -17,102 +17,91 @@ struct ImageViewerView: View {
Color.black
.ignoresSafeArea()
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(offset)
.offset(dragOffset)
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = min(max(scale * delta, 1), 4)
}
.onEnded { _ in
lastScale = 1.0
if scale < 1 {
withAnimation(.spring()) {
scale = 1
offset = .zero
}
}
if scale > 4 {
scale = 4
}
},
DragGesture()
.onChanged { value in
if scale > 1 {
let newOffset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
offset = newOffset
} else {
// Dismiss gesture when not zoomed
dragOffset = value.translation
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
if dragDistance > 50 {
isDraggingToDismiss = true
}
}
}
.onEnded { value in
if scale <= 1 {
lastOffset = offset
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
if dragDistance > 100 || velocity > 500 {
dismiss()
} else {
withAnimation(.spring()) {
dragOffset = .zero
isDraggingToDismiss = false
}
}
} else {
lastOffset = offset
}
}
)
)
.onTapGesture(count: 2) {
withAnimation(.spring()) {
if scale > 1 {
scale = 1
offset = .zero
lastOffset = .zero
} else {
scale = 2
CachedAsyncImage(url: URL(string: imageUrl))
.scaledToFit()
.scaleEffect(scale)
.offset(offset)
.offset(dragOffset)
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = min(max(scale * delta, 1), 4)
}
.onEnded { _ in
lastScale = 1.0
if scale < 1 {
withAnimation(.spring()) {
scale = 1
offset = .zero
}
}
if scale > 4 {
scale = 4
}
},
DragGesture()
.onChanged { value in
if scale > 1 {
let newOffset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
offset = newOffset
} else {
// Dismiss gesture when not zoomed
dragOffset = value.translation
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
if dragDistance > 50 {
isDraggingToDismiss = true
}
}
}
.onEnded { value in
if scale <= 1 {
lastOffset = offset
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
if dragDistance > 100 || velocity > 500 {
dismiss()
} else {
withAnimation(.spring()) {
dragOffset = .zero
isDraggingToDismiss = false
}
}
} else {
lastOffset = offset
}
}
)
)
.onTapGesture(count: 2) {
withAnimation(.spring()) {
if scale > 1 {
scale = 1
offset = .zero
lastOffset = .zero
} else {
scale = 2
}
}
} placeholder: {
ProgressView()
.scaleEffect(1.5)
.foregroundColor(.white)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
.foregroundColor(.white)
}
.foregroundColor(.white)
}
}
}
}
}
#Preview {
ImageViewerView(imageUrl: "https://example.com/image.jpg")
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import Foundation
import SafariServices
extension View {
@ -12,35 +13,192 @@ extension View {
}
struct BookmarkCardView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var appSettings: AppSettings
let bookmark: Bookmark
let currentState: BookmarkState
let layout: CardLayoutStyle
let pendingDelete: PendingDelete?
let onArchive: (Bookmark) -> Void
let onDelete: (Bookmark) -> Void
let onToggleFavorite: (Bookmark) -> Void
let namespace: Namespace.ID?
let onUndoDelete: ((String) -> Void)?
init(
bookmark: Bookmark,
currentState: BookmarkState,
layout: CardLayoutStyle = .magazine,
pendingDelete: PendingDelete? = nil,
onArchive: @escaping (Bookmark) -> Void,
onDelete: @escaping (Bookmark) -> Void,
onToggleFavorite: @escaping (Bookmark) -> Void,
onUndoDelete: ((String) -> Void)? = nil
) {
self.bookmark = bookmark
self.currentState = currentState
self.layout = layout
self.pendingDelete = pendingDelete
self.onArchive = onArchive
self.onDelete = onDelete
self.onToggleFavorite = onToggleFavorite
self.onUndoDelete = onUndoDelete
}
var body: some View {
ZStack(alignment: .bottom) {
Group {
switch layout {
case .compact:
compactLayoutView
case .magazine:
magazineLayoutView
case .natural:
naturalLayoutView
}
}
.opacity(pendingDelete != nil ? 0.4 : 1.0)
.animation(.easeInOut(duration: 0.2), value: pendingDelete != nil)
// Undo toast overlay with progress background
if let pendingDelete = pendingDelete {
VStack(spacing: 0) {
Spacer()
// Undo button area with circular progress
HStack {
HStack(spacing: 8) {
// Circular progress indicator
ZStack {
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: 2)
.frame(width: 16, height: 16)
Circle()
.trim(from: 0, to: CGFloat(pendingDelete.progress))
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.animation(.linear(duration: 0.1), value: pendingDelete.progress)
}
Text("Deleting...")
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
Button("Undo") {
onUndoDelete?(bookmark.id)
}
.font(.caption.weight(.medium))
.foregroundColor(.blue)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
.onTapGesture {
onUndoDelete?(bookmark.id)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemBackground).opacity(0.95))
}
.clipShape(RoundedRectangle(cornerRadius: layout == .compact ? 8 : 12))
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if pendingDelete == nil {
Button("Delete", role: .destructive) {
onDelete(bookmark)
}
.tint(.red)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if pendingDelete == nil {
Button {
onArchive(bookmark)
} label: {
if currentState == .archived {
Label("Restore", systemImage: "tray.and.arrow.up")
} else {
Label("Archive", systemImage: "archivebox")
}
}
.tint(currentState == .archived ? .blue : .orange)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Remove" : "Favorite",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
}
.tint(bookmark.isMarked ? .gray : .pink)
}
}
}
private var compactLayoutView: some View {
HStack(alignment: .top, spacing: 12) {
CachedAsyncImage(url: imageURL)
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
if !bookmark.description.isEmpty {
Text(bookmark.description)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
}
HStack(spacing: 4) {
if !bookmark.siteName.isEmpty {
HStack(spacing: 2) {
Image(systemName: "globe")
Text(bookmark.siteName)
}
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if let readingTime = bookmark.readingTime, readingTime > 0 {
HStack(spacing: 2) {
Image(systemName: "clock")
Text("\(readingTime) min")
}
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.padding(12)
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var magazineLayoutView: some View {
VStack(alignment: .leading, spacing: 8) {
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))
.if(namespace != nil) { view in
view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!)
}
CachedAsyncImage(url: imageURL)
.aspectRatio(contentMode: .fill)
.frame(height: 140)
.clipShape(RoundedRectangle(cornerRadius: 8))
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
ZStack {
@ -77,15 +235,12 @@ struct BookmarkCardView: View {
VStack(alignment: .leading, spacing: 4) {
HStack {
// Published date
if let publishedDate = formattedPublishedDate {
HStack {
Label(publishedDate, systemImage: "calendar")
Spacer()
}
Spacer() // show spacer only if we have the published Date
Spacer()
}
if let readingTime = bookmark.readingTime, readingTime > 0 {
@ -99,49 +254,102 @@ struct BookmarkCardView: View {
}
}
HStack {
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
.onTapGesture {
SafariUtil.openInSafari(url: bookmark.url)
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
}
}
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.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("Delete", role: .destructive) {
onDelete(bookmark)
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
// Archive (left)
Button {
onArchive(bookmark)
} label: {
if currentState == .archived {
Label("Restore", systemImage: "tray.and.arrow.up")
} else {
Label("Archive", systemImage: "archivebox")
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
}
private var naturalLayoutView: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottomTrailing) {
CachedAsyncImage(url: imageURL)
.aspectRatio(contentMode: .fill)
.frame(width: UIScreen.main.bounds.width - 32)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 8))
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
ZStack {
Circle()
.fill(Color(.systemBackground))
.frame(width: 36, height: 36)
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
.frame(width: 32, height: 32)
Circle()
.trim(from: 0, to: CGFloat(bookmark.readProgress) / 100)
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 32, height: 32)
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text("\(bookmark.readProgress)")
.font(.caption2)
.bold()
Text("%")
.font(.system(size: 8))
.baselineOffset(2)
}
}
.padding(8)
}
}
.tint(currentState == .archived ? .blue : .orange)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Remove" : "Favorite",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
HStack {
if let publishedDate = formattedPublishedDate {
HStack {
Label(publishedDate, systemImage: "calendar")
Spacer()
}
Spacer()
}
if let readingTime = bookmark.readingTime, readingTime > 0 {
Label("\(readingTime) min", systemImage: "clock")
}
}
HStack {
if !bookmark.siteName.isEmpty {
Label(bookmark.siteName, systemImage: "globe")
}
}
HStack {
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
.onTapGesture {
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
}
}
}
.font(.caption)
.foregroundColor(.secondary)
}
.tint(bookmark.isMarked ? .gray : .pink)
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
}
// MARK: - Computed Properties
@ -156,13 +364,10 @@ struct BookmarkCardView: View {
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
guard let date = formatter.date(from: published) else {
// Fallback without milliseconds
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
guard let fallbackDate = formatter.date(from: published) else {
return nil
}
@ -173,18 +378,19 @@ struct BookmarkCardView: View {
}
private func formatDate(_ date: Date) -> String {
let now = Date()
let calendar = Calendar.current
let now = Date()
// Today
if calendar.isDateInToday(date) {
if calendar.isDate(date, inSameDayAs: now) {
let formatter = DateFormatter()
formatter.timeStyle = .short
return "Today, \(formatter.string(from: date))"
}
// Yesterday
if calendar.isDateInYesterday(date) {
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
calendar.isDate(date, inSameDayAs: yesterday) {
let formatter = DateFormatter()
formatter.timeStyle = .short
return "Yesterday, \(formatter.string(from: date))"
@ -211,13 +417,8 @@ struct BookmarkCardView: View {
}
private var imageURL: URL? {
// 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 {
return URL(string: thumbnailUrl)
} else if let iconUrl = bookmark.resources.icon?.src {
return URL(string: iconUrl)
}
return nil
}
@ -229,11 +430,9 @@ struct IconBadge: View {
var body: some View {
Image(systemName: systemName)
.font(.caption2)
.padding(6)
.background(color.opacity(0.2))
.foregroundColor(color)
.frame(width: 20, height: 20)
.background(color)
.foregroundColor(.white)
.clipShape(Circle())
}
}

View File

@ -4,8 +4,6 @@ import SwiftUI
struct BookmarksView: View {
@Namespace private var namespace
// MARK: States
@State private var viewModel: BookmarksViewModel
@ -14,7 +12,6 @@ struct BookmarksView: View {
@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]
@ -39,14 +36,16 @@ struct BookmarksView: View {
var body: some View {
ZStack {
if shouldShowCenteredState {
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
skeletonLoadingView
} else if shouldShowCenteredState {
centeredStateView
} else {
bookmarksList
}
// FAB Button - only show for "Unread" and when not in error/loading state
if (state == .unread || state == .all) && !shouldShowCenteredState {
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
fabButton
}
}
@ -56,8 +55,8 @@ struct BookmarksView: View {
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
BookmarkDetailView(bookmarkId: bookmarkId)
.toolbar(.hidden, for: .tabBar)
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
@ -68,18 +67,6 @@ struct BookmarksView: View {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
)
.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)
@ -102,7 +89,8 @@ struct BookmarksView: View {
private var shouldShowCenteredState: Bool {
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
return isEmpty && (viewModel.isLoading || viewModel.errorMessage != nil)
let hasError = viewModel.errorMessage != nil
return (isEmpty && viewModel.isLoading) || hasError
}
// MARK: - View Components
@ -148,16 +136,16 @@ struct BookmarksView: View {
@ViewBuilder
private func errorView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
Image(systemName: viewModel.isNetworkError ? "wifi.slash" : "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundColor(.orange)
VStack(spacing: 8) {
Text("Unable to load bookmarks")
Text(viewModel.isNetworkError ? "No internet connection" : "Unable to load bookmarks")
.font(.headline)
.foregroundColor(.primary)
Text(message)
Text(viewModel.isNetworkError ? "Please check your internet connection and try again" : message)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
@ -165,7 +153,7 @@ struct BookmarksView: View {
Button("Try Again") {
Task {
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
await viewModel.retryLoading()
}
}
.buttonStyle(.borderedProminent)
@ -179,6 +167,11 @@ struct BookmarksView: View {
List {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
Button(action: {
// Don't navigate to detail if bookmark is pending deletion
if viewModel.pendingDeletes[bookmark.id] != nil {
return
}
if UIDevice.isPhone {
selectedBookmarkId = bookmark.id
} else {
@ -195,20 +188,24 @@ struct BookmarksView: View {
BookmarkCardView(
bookmark: bookmark,
currentState: state,
layout: viewModel.cardLayoutStyle,
pendingDelete: viewModel.pendingDeletes[bookmark.id],
onArchive: { bookmark in
Task {
await viewModel.toggleArchive(bookmark: bookmark)
}
},
onDelete: { bookmark in
bookmarkToDelete = bookmark
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
},
namespace: namespace
onUndoDelete: { bookmarkId in
viewModel.undoDelete(bookmarkId: bookmarkId)
}
)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
@ -219,10 +216,14 @@ struct BookmarksView: View {
}
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowInsets(EdgeInsets(
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
leading: 16,
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
trailing: 16
))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
.matchedTransitionSource(id: bookmark.id, in: namespace)
}
// Show loading indicator for pagination
@ -256,6 +257,25 @@ struct BookmarksView: View {
}
}
@ViewBuilder
private var skeletonLoadingView: some View {
ScrollView {
SkeletonLoadingView(layout: viewModel.cardLayoutStyle)
.padding(
EdgeInsets(
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
leading: 16,
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
trailing: 16
)
)
}
.background(Color(R.color.bookmark_list_bg))
.refreshable {
await viewModel.refreshBookmarks()
}
}
@ViewBuilder
private var fabButton: some View {
VStack {

View File

@ -7,21 +7,31 @@ class BookmarksViewModel {
private let getBooksmarksUseCase: PGetBookmarksUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
var bookmarks: BookmarksPage?
var isLoading = false
var isInitialLoading = true
var errorMessage: String?
var isNetworkError = false
var currentState: BookmarkState = .unread
var currentType = [BookmarkType.article]
var currentTag: String? = nil
var cardLayoutStyle: CardLayoutStyle = .magazine
var showingAddBookmarkFromShare = false
var shareURL = ""
var shareTitle = ""
// Undo delete functionality
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
// Prevent concurrent updates
private var isUpdating = false
private var cancellables = Set<AnyCancellable>()
private var limit = 20
private var limit = 50
private var offset = 0
private var hasMoreData = true
private var searchWorkItem: DispatchWorkItem?
@ -36,13 +46,31 @@ class BookmarksViewModel {
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
setupNotificationObserver()
Task {
await loadCardLayout()
}
}
private func setupNotificationObserver() {
// Listen for card layout changes
NotificationCenter.default
.publisher(for: NSNotification.Name("AddBookmarkFromShare"))
.publisher(for: .cardLayoutChanged)
.sink { notification in
if let layout = notification.object as? CardLayoutStyle {
Task { @MainActor in
self.cardLayoutStyle = layout
}
}
}
.store(in: &cancellables)
// Listen for
NotificationCenter.default
.publisher(for: .addBookmarkFromShare)
.sink { [weak self] notification in
self?.handleShareNotification(notification)
}
@ -79,15 +107,19 @@ class BookmarksViewModel {
@MainActor
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
guard !isUpdating else { return }
isUpdating = true
defer { isUpdating = false }
isLoading = true
errorMessage = nil
currentState = state
currentType = type
currentTag = tag
offset = 0
hasMoreData = true
do {
let newBookmarks = try await getBooksmarksUseCase.execute(
state: state,
@ -99,21 +131,38 @@ class BookmarksViewModel {
)
bookmarks = newBookmarks
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available
isNetworkError = false
} catch {
errorMessage = "Error loading bookmarks"
// Check if it's a network error
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
isNetworkError = true
errorMessage = "No internet connection"
default:
isNetworkError = false
errorMessage = "Error loading bookmarks"
}
} else {
isNetworkError = false
errorMessage = "Error loading bookmarks"
}
// Don't clear bookmarks on error - keep existing data visible
}
isLoading = false
isInitialLoading = false
}
@MainActor
func loadMoreBookmarks() async {
guard !isLoading && hasMoreData else { return } // prevent multiple loads
guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads
isUpdating = true
defer { isUpdating = false }
isLoading = true
errorMessage = nil
do {
offset += limit // inc. offset
let newBookmarks = try await getBooksmarksUseCase.execute(
@ -126,9 +175,22 @@ class BookmarksViewModel {
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
} catch {
errorMessage = "Error loading more bookmarks"
// Check if it's a network error
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
isNetworkError = true
errorMessage = "No internet connection"
default:
isNetworkError = false
errorMessage = "Error loading more bookmarks"
}
} else {
isNetworkError = false
errorMessage = "Error loading more bookmarks"
}
}
isLoading = false
}
@ -137,6 +199,13 @@ class BookmarksViewModel {
await loadBookmarks(state: currentState)
}
@MainActor
func retryLoading() async {
errorMessage = nil
isNetworkError = false
await loadBookmarks(state: currentState, type: currentType, tag: currentTag)
}
@MainActor
func toggleArchive(bookmark: Bookmark) async {
do {
@ -168,14 +237,101 @@ class BookmarksViewModel {
}
@MainActor
func deleteBookmark(bookmark: Bookmark) async {
func deleteBookmarkWithUndo(bookmark: Bookmark) {
// Don't remove from UI immediately - just mark as pending
let pendingDelete = PendingDelete(bookmark: bookmark)
pendingDeletes[bookmark.id] = pendingDelete
// Start countdown timer for this specific delete
startDeleteCountdown(for: bookmark.id)
// Schedule actual delete after 3 seconds
let deleteTask = Task {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
// Check if not cancelled and still pending
if !Task.isCancelled, pendingDeletes[bookmark.id] != nil {
await executeDelete(bookmark: bookmark)
await MainActor.run {
// Clean up
pendingDeletes[bookmark.id]?.timer?.invalidate()
pendingDeletes.removeValue(forKey: bookmark.id)
}
}
}
// Store the task in the pending delete
pendingDeletes[bookmark.id]?.deleteTask = deleteTask
}
@MainActor
func undoDelete(bookmarkId: String) {
guard let pendingDelete = pendingDeletes[bookmarkId] else { return }
// Cancel the delete task and timer
pendingDelete.deleteTask?.cancel()
pendingDelete.timer?.invalidate()
// Remove from pending deletes
pendingDeletes.removeValue(forKey: bookmarkId)
}
private func startDeleteCountdown(for bookmarkId: String) {
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
DispatchQueue.main.async {
guard let self = self,
let pendingDelete = self.pendingDeletes[bookmarkId] else {
timer.invalidate()
return
}
pendingDelete.progress += 1.0 / 30.0 // 3 seconds / 0.1 interval = 30 steps
// Trigger UI update by modifying the dictionary
self.pendingDeletes[bookmarkId] = pendingDelete
if pendingDelete.progress >= 1.0 {
timer.invalidate()
}
}
}
pendingDeletes[bookmarkId]?.timer = timer
}
private func executeDelete(bookmark: Bookmark) async {
do {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
// If delete succeeds, remove bookmark from the list
await MainActor.run {
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
}
} catch {
errorMessage = "Error deleting bookmark"
await loadBookmarks(state: currentState)
// If delete fails, restore the bookmark
await MainActor.run {
errorMessage = "Error deleting bookmark"
if var currentBookmarks = bookmarks?.bookmarks {
currentBookmarks.insert(bookmark, at: 0)
bookmarks?.bookmarks = currentBookmarks
}
}
}
}
@MainActor
private func loadCardLayout() async {
cardLayoutStyle = await loadCardLayoutUseCase.execute()
}
}
class PendingDelete: Identifiable {
let id = UUID()
let bookmark: Bookmark
var progress: Double = 0.0
var timer: Timer?
var deleteTask: Task<Void, Never>?
init(bookmark: Bookmark) {
self.bookmark = bookmark
}
}

View File

@ -0,0 +1,26 @@
import SwiftUI
import Kingfisher
struct CachedAsyncImage: View {
let url: URL?
init(url: URL?) {
self.url = url
}
var body: some View {
if let url {
KFImage(url)
.placeholder {
Color.gray.opacity(0.3)
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
} else {
Image("placeholder")
.resizable()
.scaledToFill()
}
}
}

View File

@ -10,9 +10,53 @@
//
import Foundation
import SwiftUI
struct Constants {
struct Labels {
static let pageSize = 12
// Annotation colors
static let annotationColors: [AnnotationColor] = [.yellow, .green, .blue, .red]
}
enum AnnotationColor: String, CaseIterable, Codable {
case yellow = "yellow"
case green = "green"
case blue = "blue"
case red = "red"
// Base hex color for buttons and overlays
var hexColor: String {
switch self {
case .yellow: return "#D4A843"
case .green: return "#6FB546"
case .blue: return "#4A9BB8"
case .red: return "#C84848"
}
}
// RGB values for SwiftUI Color
private var rgb: (red: Double, green: Double, blue: Double) {
switch self {
case .yellow: return (212, 168, 67)
case .green: return (111, 181, 70)
case .blue: return (74, 155, 184)
case .red: return (200, 72, 72)
}
}
func swiftUIColor(isDark: Bool) -> Color {
let (r, g, b) = rgb
return Color(red: r/255, green: g/255, blue: b/255)
}
// CSS rgba string for JavaScript (for highlighting)
func cssColor(isDark: Bool) -> String {
let (r, g, b) = rgb
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), 0.3)"
}
// CSS rgba string with custom opacity
func cssColorWithOpacity(_ opacity: Double) -> String {
let (r, g, b) = rgb
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), \(opacity))"
}
}

View File

@ -0,0 +1,330 @@
import SwiftUI
import CoreData
struct CoreDataTagManagementView: View {
// MARK: - Properties
let selectedLabelsSet: Set<String>
let searchText: Binding<String>
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
let sortOrder: TagSortOrder
let availableLabelsTitle: String?
let context: NSManagedObjectContext
// MARK: - Callbacks
let onAddCustomTag: () -> Void
let onToggleLabel: (String) -> Void
let onRemoveLabel: (String) -> Void
// MARK: - FetchRequest
@FetchRequest
private var tagEntities: FetchedResults<TagEntity>
// MARK: - Search State
@State private var searchResults: [TagEntity] = []
@State private var isSearchActive: Bool = false
// MARK: - Initialization
init(
selectedLabels: Set<String>,
searchText: Binding<String>,
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
fetchLimit: Int? = nil,
sortOrder: TagSortOrder = .byCount,
availableLabelsTitle: String? = nil,
context: NSManagedObjectContext,
onAddCustomTag: @escaping () -> Void,
onToggleLabel: @escaping (String) -> Void,
onRemoveLabel: @escaping (String) -> Void
) {
self.selectedLabelsSet = selectedLabels
self.searchText = searchText
self.searchFieldFocus = searchFieldFocus
self.sortOrder = sortOrder
self.availableLabelsTitle = availableLabelsTitle
self.context = context
self.onAddCustomTag = onAddCustomTag
self.onToggleLabel = onToggleLabel
self.onRemoveLabel = onRemoveLabel
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
// Apply sort order from parameter
let sortDescriptors: [NSSortDescriptor]
switch sortOrder {
case .byCount:
sortDescriptors = [
NSSortDescriptor(keyPath: \TagEntity.count, ascending: false),
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
]
case .alphabetically:
sortDescriptors = [
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
]
}
fetchRequest.sortDescriptors = sortDescriptors
if let limit = fetchLimit {
fetchRequest.fetchLimit = limit
}
fetchRequest.fetchBatchSize = 20
_tagEntities = FetchRequest(
fetchRequest: fetchRequest,
animation: .default
)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
searchField
customTagSuggestion
availableLabels
selectedLabels
}
.onChange(of: searchText.wrappedValue) { oldValue, newValue in
performSearch(query: newValue)
}
}
// MARK: - View Components
@ViewBuilder
private var searchField: some View {
TextField("Search or add new label...", text: searchText)
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.default)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onSubmit {
onAddCustomTag()
}
.modifier(FocusModifier(focusBinding: searchFieldFocus, field: .labels))
}
@ViewBuilder
private var customTagSuggestion: some View {
if !searchText.wrappedValue.isEmpty &&
!allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) &&
!selectedLabelsSet.contains(searchText.wrappedValue) {
HStack {
Text("Add new label:")
.font(.subheadline)
.foregroundColor(.secondary)
Text(searchText.wrappedValue)
.font(.subheadline)
.fontWeight(.medium)
Spacer()
Button(action: onAddCustomTag) {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill")
.font(.subheadline)
Text("Add")
.font(.subheadline)
.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 !tagEntities.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(searchText.wrappedValue.isEmpty ? (availableLabelsTitle ?? "Available labels") : "Search results")
.font(.subheadline)
.fontWeight(.medium)
if !searchText.wrappedValue.isEmpty {
Text("(\(filteredTagsCount) found)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
if availableUnselectedTagsCount == 0 {
// Show "All labels selected" only if there are actually filtered results
// Otherwise show "No labels found" for empty search results
if filteredTagsCount > 0 {
VStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
.foregroundColor(.green)
Text("All labels selected")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else if !searchText.wrappedValue.isEmpty {
VStack {
Image(systemName: "magnifyingglass")
.font(.system(size: 24))
.foregroundColor(.secondary)
Text("No labels found")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
}
} else {
labelsScrollView
}
}
.padding(.top, 8)
}
}
@ViewBuilder
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(
rows: [
GridItem(.fixed(32), spacing: 8),
GridItem(.fixed(32), spacing: 8),
GridItem(.fixed(32), spacing: 8)
],
alignment: .top,
spacing: 8
) {
// Use searchResults when search is active, otherwise use tagEntities
let tagsToDisplay = isSearchActive ? searchResults : Array(tagEntities)
ForEach(tagsToDisplay, id: \.objectID) { entity in
if let name = entity.name {
// When searching, show all results (already filtered by predicate)
// When not searching, filter with shouldShowTag()
let shouldShow = isSearchActive ? !selectedLabelsSet.contains(name) : shouldShowTag(name)
if shouldShow {
UnifiedLabelChip(
label: name,
isSelected: false,
isRemovable: false,
onTap: {
onToggleLabel(name)
}
)
.fixedSize(horizontal: true, vertical: false)
}
}
}
}
.frame(height: 120) // 3 rows * 32px + 2 * 8px spacing
.padding(.horizontal)
}
}
// MARK: - Computed Properties & Helper Functions
private var allTagNames: [String] {
tagEntities.compactMap { $0.name }
}
private var filteredTagsCount: Int {
if isSearchActive {
return searchResults.count
} else if searchText.wrappedValue.isEmpty {
return tagEntities.count
} else {
return tagEntities.filter { entity in
guard let name = entity.name else { return false }
return name.localizedCaseInsensitiveContains(searchText.wrappedValue)
}.count
}
}
private var availableUnselectedTagsCount: Int {
if isSearchActive {
return searchResults.filter { entity in
guard let name = entity.name else { return false }
return !selectedLabelsSet.contains(name)
}.count
} else {
return tagEntities.filter { entity in
guard let name = entity.name else { return false }
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
let isNotSelected = !selectedLabelsSet.contains(name)
return matchesSearch && isNotSelected
}.count
}
}
private func shouldShowTag(_ name: String) -> Bool {
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
let isNotSelected = !selectedLabelsSet.contains(name)
return matchesSearch && isNotSelected
}
private func performSearch(query: String) {
guard !query.isEmpty else {
isSearchActive = false
searchResults = []
return
}
// Search directly in Core Data without fetchLimit
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", query)
// Use same sort order as main fetch
let sortDescriptors: [NSSortDescriptor]
switch sortOrder {
case .byCount:
sortDescriptors = [
NSSortDescriptor(keyPath: \TagEntity.count, ascending: false),
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
]
case .alphabetically:
sortDescriptors = [
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
]
}
fetchRequest.sortDescriptors = sortDescriptors
// NO fetchLimit - search ALL tags in database
searchResults = (try? context.fetch(fetchRequest)) ?? []
isSearchActive = true
}
@ViewBuilder
private var selectedLabels: some View {
if !selectedLabelsSet.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Selected labels")
.font(.subheadline)
.fontWeight(.medium)
FlowLayout(spacing: 8) {
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: true,
isRemovable: true,
onTap: {
// No action for selected labels
},
onRemove: {
onRemoveLabel(label)
}
)
}
}
}
.padding(.top, 8)
}
}
}

View File

@ -1,5 +1,65 @@
// TODO: deprecated - This file is no longer used and can be removed
// Replaced by CoreDataTagManagementView.swift which uses Core Data directly
// instead of fetching labels via API
import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = FlowResult(
in: proposal.replacingUnspecifiedDimensions().width,
subviews: subviews,
spacing: spacing
)
return result.bounds
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = FlowResult(
in: bounds.width,
subviews: subviews,
spacing: spacing
)
for (index, subview) in subviews.enumerated() {
subview.place(at: CGPoint(
x: bounds.minX + result.frames[index].minX,
y: bounds.minY + result.frames[index].minY
), proposal: ProposedViewSize(result.frames[index].size))
}
}
}
struct FlowResult {
var frames: [CGRect] = []
var bounds: CGSize = .zero
init(in maxWidth: CGFloat, subviews: LayoutSubviews, spacing: CGFloat) {
var x: CGFloat = 0
var y: CGFloat = 0
var lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += lineHeight + spacing
lineHeight = 0
}
frames.append(CGRect(x: x, y: y, width: size.width, height: size.height))
lineHeight = max(lineHeight, size.height)
x += size.width + spacing
bounds.width = max(bounds.width, x - spacing)
}
bounds.height = y + lineHeight
}
}
enum AddBookmarkFieldFocus {
case url
case labels
@ -19,7 +79,7 @@ struct FocusModifier: ViewModifier {
}
}
struct TagManagementView: View {
struct LegacyTagManagementView: View {
// MARK: - Properties
@ -27,7 +87,6 @@ struct TagManagementView: View {
let selectedLabelsSet: Set<String>
let searchText: Binding<String>
let isLabelsLoading: Bool
let availableLabelPages: [[BookmarkLabel]]
let filteredLabels: [BookmarkLabel]
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
@ -44,7 +103,6 @@ struct TagManagementView: View {
selectedLabels: Set<String>,
searchText: Binding<String>,
isLabelsLoading: Bool,
availableLabelPages: [[BookmarkLabel]],
filteredLabels: [BookmarkLabel],
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
onAddCustomTag: @escaping () -> Void,
@ -55,7 +113,6 @@ struct TagManagementView: View {
self.selectedLabelsSet = selectedLabels
self.searchText = searchText
self.isLabelsLoading = isLabelsLoading
self.availableLabelPages = availableLabelPages
self.filteredLabels = filteredLabels
self.searchFieldFocus = searchFieldFocus
self.onAddCustomTag = onAddCustomTag
@ -80,6 +137,7 @@ struct TagManagementView: View {
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.default)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onSubmit {
onAddCustomTag()
}
@ -138,7 +196,7 @@ struct TagManagementView: View {
.scaleEffect(0.8)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
} else if availableLabelPages.isEmpty {
} else if allLabels.isEmpty {
VStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
@ -150,7 +208,7 @@ struct TagManagementView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
labelsTabView
labelsScrollView
}
}
.padding(.top, 8)
@ -158,28 +216,47 @@ struct TagManagementView: View {
}
@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)
}
)
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(chunkedLabels, id: \.self) { rowLabels in
HStack(alignment: .top, spacing: 8) {
ForEach(rowLabels, id: \.id) { label in
UnifiedLabelChip(
label: label.name,
isSelected: false,
isRemovable: false,
onTap: {
onToggleLabel(label.name)
}
)
}
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal)
}
.padding(.horizontal)
}
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
.frame(height: 180)
.padding(.top, 10)
.frame(height: calculateMaxHeight())
}
private var chunkedLabels: [[BookmarkLabel]] {
let maxRows = 3
let labelsPerRow = max(1, availableUnselectedLabels.count / maxRows + (availableUnselectedLabels.count % maxRows > 0 ? 1 : 0))
return availableUnselectedLabels.chunked(into: labelsPerRow)
}
private var availableUnselectedLabels: [BookmarkLabel] {
let labelsToShow = searchText.wrappedValue.isEmpty ? allLabels : filteredLabels
return labelsToShow.filter { !selectedLabelsSet.contains($0.name) }
}
private func calculateMaxHeight() -> CGFloat {
// Berechne Höhe für maximal 3 Reihen
let rowHeight: CGFloat = 32 // Höhe eines Labels
let spacing: CGFloat = 8
let maxRows: CGFloat = 3
return (rowHeight * maxRows) + (spacing * (maxRows - 1))
}
@ViewBuilder
@ -190,11 +267,11 @@ struct TagManagementView: View {
.font(.subheadline)
.fontWeight(.medium)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
FlowLayout(spacing: 8) {
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: false,
isSelected: true,
isRemovable: true,
onTap: {
// No action for selected labels
@ -210,3 +287,11 @@ struct TagManagementView: View {
}
}
}
extension Array {
func chunked(into size: Int) -> [[Element]] {
return stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}
}

View File

@ -0,0 +1,717 @@
import SwiftUI
import WebKit
// MARK: - iOS 26+ Native SwiftUI WebView Implementation
// This implementation is available but not currently used
// To activate: Replace WebView usage with hybrid approach using #available(iOS 26.0, *)
@available(iOS 26.0, *)
struct NativeWebView: View {
let htmlContent: String
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
var onScrollToPosition: ((CGFloat) -> Void)? = nil
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
var body: some View {
WebKit.WebView(webPage)
.scrollDisabled(true) // Disable internal scrolling
.onAppear {
loadStyledContent()
setupAnnotationMessageHandler()
setupScrollToPositionHandler()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
}
.onChange(of: colorScheme) { _, _ in
loadStyledContent()
}
.onChange(of: selectedAnnotationId) { _, _ in
loadStyledContent()
}
.onChange(of: webPage.isLoading) { _, isLoading in
if !isLoading {
// Update height when content finishes loading
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
Task {
await updateContentHeightWithJS()
}
}
}
}
}
private func setupAnnotationMessageHandler() {
guard let onAnnotationCreated = onAnnotationCreated else { return }
// Poll for annotation messages from JavaScript
Task { @MainActor in
let page = webPage
while true {
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
let script = """
return (function() {
if (window.__pendingAnnotation) {
const data = window.__pendingAnnotation;
window.__pendingAnnotation = null;
return data;
}
return null;
})();
"""
do {
if let result = try await page.callJavaScript(script) as? [String: Any],
let color = result["color"] as? String,
let text = result["text"] as? String,
let startOffset = result["startOffset"] as? Int,
let endOffset = result["endOffset"] as? Int,
let startSelector = result["startSelector"] as? String,
let endSelector = result["endSelector"] as? String {
onAnnotationCreated(color, text, startOffset, endOffset, startSelector, endSelector)
}
} catch {
// Silently continue polling
}
}
}
}
private func setupScrollToPositionHandler() {
guard let onScrollToPosition = onScrollToPosition else { return }
// Poll for scroll position messages from JavaScript
Task { @MainActor in
let page = webPage
while true {
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
let script = """
return (function() {
if (window.__pendingScrollPosition !== undefined) {
const position = window.__pendingScrollPosition;
window.__pendingScrollPosition = undefined;
return position;
}
return null;
})();
"""
do {
if let position = try await page.callJavaScript(script) as? Double {
onScrollToPosition(CGFloat(position))
}
} catch {
// Silently continue polling
}
}
}
}
private func updateContentHeightWithJS() async {
var lastHeight: CGFloat = 0
// Similar strategy to WebView: multiple attempts with increasing delays
let delays = [0.1, 0.2, 0.5, 1.0, 1.5, 2.0] // 6 attempts like WebView
for (index, delay) in delays.enumerated() {
let attempt = index + 1
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
do {
// Try to get height via JavaScript - use simple document.body.scrollHeight
let result = try await webPage.callJavaScript("return document.body.scrollHeight")
if let height = result as? Double, height > 0 {
let cgHeight = CGFloat(height)
// Update height if it's significantly different (> 5px like WebView)
if lastHeight == 0 || abs(cgHeight - lastHeight) > 5 {
print("🟢 NativeWebView - JavaScript height updated: \(height)px on attempt \(attempt)")
DispatchQueue.main.async {
self.onHeightChange(cgHeight)
}
lastHeight = cgHeight
}
// If height seems stable (no change in last 2 attempts), we can exit early
if attempt >= 2 && lastHeight > 0 {
print("🟢 NativeWebView - Height stabilized at \(lastHeight)px after \(attempt) attempts")
return
}
}
} catch {
print("🟡 NativeWebView - JavaScript attempt \(attempt) failed: \(error)")
}
}
// If no valid height was found, use fallback
if lastHeight == 0 {
print("🔴 NativeWebView - No valid JavaScript height found, using fallback")
updateContentHeightFallback()
} else {
print("🟢 NativeWebView - Final height: \(lastHeight)px")
}
}
private func updateContentHeightFallback() {
// Simplified fallback calculation
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
let plainText = htmlContent.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
let characterCount = plainText.count
let estimatedLines = max(1, characterCount / 80)
let textHeight = CGFloat(estimatedLines) * CGFloat(fontSize) * 1.8
let finalHeight = max(400, min(textHeight + 100, 3000))
print("🟡 NativeWebView - Using fallback height: \(finalHeight)px")
DispatchQueue.main.async {
self.onHeightChange(finalHeight)
}
}
private func loadStyledContent() {
let isDarkMode = colorScheme == .dark
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
let styledHTML = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
<style>
* {
max-width: 100%;
box-sizing: border-box;
}
html {
overflow-x: hidden;
width: 100%;
}
body {
font-family: \(fontFamily);
line-height: 1.8;
margin: 0;
padding: 16px;
background-color: \(isDarkMode ? "#000000" : "#ffffff");
color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
font-size: \(fontSize)px;
-webkit-text-size-adjust: 100%;
-webkit-user-select: text;
user-select: text;
overflow-x: hidden;
width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
h1, h2, h3, h4, h5, h6 {
color: \(isDarkMode ? "#ffffff" : "#000000");
margin-top: 24px;
margin-bottom: 12px;
font-weight: 600;
}
h1 { font-size: \(fontSize * 3 / 2)px; }
h2 { font-size: \(fontSize * 5 / 4)px; }
h3 { font-size: \(fontSize * 9 / 8)px; }
p { margin-bottom: 16px; }
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 16px 0;
}
a { color: \(isDarkMode ? "#0A84FF" : "#007AFF"); text-decoration: none; }
a:hover { text-decoration: underline; }
blockquote {
border-left: 4px solid \(isDarkMode ? "#0A84FF" : "#007AFF");
margin: 16px 0;
padding: 12px 16px;
font-style: italic;
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)");
border-radius: 4px;
}
code {
background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
color: \(isDarkMode ? "#ffffff" : "#000000");
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', monospace;
}
pre {
background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
color: \(isDarkMode ? "#ffffff" : "#000000");
padding: 16px;
border-radius: 8px;
overflow-x: auto;
max-width: 100%;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'SF Mono', monospace;
}
ul, ol { padding-left: 20px; margin-bottom: 16px; }
li { margin-bottom: 4px; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
th, td { border: 1px solid #ccc; padding: 8px 12px; text-align: left; }
th { font-weight: 600; }
hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; }
/* Annotation Highlighting - for rd-annotation tags */
rd-annotation {
border-radius: 3px;
padding: 2px 0;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
/* Yellow annotations */
rd-annotation[data-annotation-color="yellow"] {
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="yellow"].selected {
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
}
/* Green annotations */
rd-annotation[data-annotation-color="green"] {
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="green"].selected {
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
}
/* Blue annotations */
rd-annotation[data-annotation-color="blue"] {
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="blue"].selected {
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
}
/* Red annotations */
rd-annotation[data-annotation-color="red"] {
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="red"].selected {
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
}
</style>
</head>
<body>
\(htmlContent)
<script>
function measureHeight() {
return Math.max(
document.body.scrollHeight || 0,
document.body.offsetHeight || 0,
document.documentElement.clientHeight || 0,
document.documentElement.scrollHeight || 0,
document.documentElement.offsetHeight || 0
);
}
// Make function globally available
window.getContentHeight = measureHeight;
// Auto-measure when everything is ready
function scheduleHeightCheck() {
// Multiple timing strategies
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', delayedHeightCheck);
} else {
delayedHeightCheck();
}
// Also check after images load
window.addEventListener('load', delayedHeightCheck);
// Force check after layout
setTimeout(delayedHeightCheck, 50);
setTimeout(delayedHeightCheck, 100);
setTimeout(delayedHeightCheck, 200);
setTimeout(delayedHeightCheck, 500);
}
function delayedHeightCheck() {
// Force layout recalculation
document.body.offsetHeight;
const height = measureHeight();
console.log('NativeWebView height check:', height);
}
scheduleHeightCheck();
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</script>
</body>
</html>
"""
webPage.load(html: styledHTML)
// Update height after content loads
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
await updateContentHeightWithJS()
}
}
}
private func getFontSize(from fontSize: FontSize) -> Int {
switch fontSize {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
private func getFontFamily(from fontFamily: FontFamily) -> String {
switch fontFamily {
case .system: return "-apple-system, BlinkMacSystemFont, sans-serif"
case .serif: return "'Times New Roman', Times, serif"
case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif"
case .monospace: return "'SF Mono', Menlo, Monaco, monospace"
}
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
return """
// Create annotation color overlay
(function() {
let currentSelection = null;
let currentRange = null;
let selectionTimeout = null;
// Create overlay container with arrow
const overlay = document.createElement('div');
overlay.id = 'annotation-overlay';
overlay.style.cssText = `
display: none;
position: absolute;
z-index: 10000;
`;
// Create arrow/triangle pointing up with glass effect
const arrow = document.createElement('div');
arrow.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
border-bottom: none;
top: -11px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
overlay.appendChild(arrow);
// Create the actual content container with glass morphism effect
const content = document.createElement('div');
content.style.cssText = `
display: flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
gap: 12px;
flex-direction: row;
align-items: center;
`;
overlay.appendChild(content);
// Add "Markierung" label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.style.cssText = `
color: black;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
`;
content.appendChild(label);
// Create color buttons with solid colors
const colors = [
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
];
colors.forEach(({ name, color }) => {
const btn = document.createElement('button');
btn.dataset.color = name;
btn.style.cssText = `
width: 40px;
height: 40px;
border-radius: 50%;
background: ${color};
border: 3px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0;
margin: 0;
transition: transform 0.2s, border-color 0.2s;
`;
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
});
btn.addEventListener('click', () => handleColorSelection(name));
content.appendChild(btn);
});
document.body.appendChild(overlay);
// Selection change listener
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 0) {
currentSelection = text;
currentRange = selection.getRangeAt(0).cloneRange();
showOverlay(selection.getRangeAt(0));
} else {
hideOverlay();
}
}, 150);
});
function showOverlay(range) {
const rect = range.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
overlay.style.display = 'block';
// Center horizontally under selection
const overlayWidth = 320; // Approximate width with label + 4 buttons
const centerX = rect.left + (rect.width / 2);
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
// Position with extra space below selection (55px instead of 70px) to bring it closer
const topPos = rect.bottom + scrollY + 55;
overlay.style.left = leftPos + 'px';
overlay.style.top = topPos + 'px';
}
function hideOverlay() {
overlay.style.display = 'none';
currentSelection = null;
currentRange = null;
}
function calculateOffset(container, offset) {
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(container, offset);
return preRange.toString().length;
}
function getXPathSelector(node) {
// If node is text node, use parent element
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (!element || element === document.body) return 'body';
const path = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
// Count position among siblings of same tag (1-based index)
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
// Format: tagname[index] (1-based)
path.unshift(tagName + '[' + index + ']');
current = current.parentElement;
}
const selector = path.join('/');
console.log('Generated selector:', selector);
return selector || 'body';
}
function calculateOffsetInElement(container, offset) {
// Calculate offset relative to the parent element (not document.body)
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (!element) return offset;
// Create range from start of element to the position
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(container, offset);
return range.toString().length;
}
function generateTempId() {
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function handleColorSelection(color) {
if (!currentRange || !currentSelection) return;
// Generate XPath-like selectors for start and end containers
const startSelector = getXPathSelector(currentRange.startContainer);
const endSelector = getXPathSelector(currentRange.endContainer);
// Calculate offsets relative to the element (not document.body)
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
// Create annotation element
const annotation = document.createElement('rd-annotation');
annotation.setAttribute('data-annotation-color', color);
annotation.setAttribute('data-annotation-id-value', generateTempId());
// Wrap selection in annotation
try {
currentRange.surroundContents(annotation);
} catch (e) {
// If surroundContents fails (e.g., partial element selection), extract and wrap
const fragment = currentRange.extractContents();
annotation.appendChild(fragment);
currentRange.insertNode(annotation);
}
// For NativeWebView: use global variable for polling
window.__pendingAnnotation = {
color: color,
text: currentSelection,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
};
// Clear selection and hide overlay
window.getSelection().removeAllRanges();
hideOverlay();
}
})();
"""
}
private func generateScrollToAnnotationJS() -> String {
guard let selectedId = selectedAnnotationId else {
return ""
}
return """
// Scroll to selected annotation and add selected class
function scrollToAnnotation() {
// Remove 'selected' class from all annotations
document.querySelectorAll('rd-annotation.selected').forEach(el => {
el.classList.remove('selected');
});
// Find and highlight selected annotation
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) {
selectedElement.classList.add('selected');
// Get the element's position relative to the document
const rect = selectedElement.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elementTop = rect.top + scrollTop;
// Send position to Swift via polling mechanism
setTimeout(() => {
window.__pendingScrollPosition = elementTop;
}, 100);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
} else {
setTimeout(scrollToAnnotation, 300);
}
"""
}
}
// MARK: - Hybrid WebView (Not Currently Used)
// This would be the implementation to use both native and legacy WebViews
// Currently commented out - the app uses only the crash-resistant WebView
/*
struct HybridWebView: View {
let htmlContent: String
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var body: some View {
if #available(iOS 26.0, *) {
// Use new native SwiftUI WebView on iOS 26+
NativeWebView(
htmlContent: htmlContent,
settings: settings,
onHeightChange: onHeightChange,
onScroll: onScroll
)
} else {
// Fallback to crash-resistant WebView for older iOS
WebView(
htmlContent: htmlContent,
settings: settings,
onHeightChange: onHeightChange,
onScroll: onScroll
)
}
}
}
*/

View File

@ -0,0 +1,308 @@
//
// SettingsRow.swift
// readeck
//
// Created by Ilyas Hallak on 31.10.25.
//
import SwiftUI
// MARK: - Settings Row with Navigation Link
struct SettingsRowNavigationLink<Destination: View>: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let destination: Destination
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
@ViewBuilder destination: () -> Destination
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.destination = destination()
}
var body: some View {
NavigationLink(destination: destination) {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: subtitle
)
}
}
}
// MARK: - Settings Row with Toggle
struct SettingsRowToggle: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
@Binding var isOn: Bool
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
isOn: Binding<Bool>
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self._isOn = isOn
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: subtitle
)
Toggle("", isOn: $isOn)
.labelsHidden()
}
}
}
// MARK: - Settings Row with Value Display
struct SettingsRowValue: View {
let icon: String?
let iconColor: Color
let title: String
let value: String
let valueColor: Color
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
value: String,
valueColor: Color = .secondary
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.value = value
self.valueColor = valueColor
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: nil
)
Spacer()
Text(value)
.foregroundColor(valueColor)
}
}
}
// MARK: - Settings Row Button (for actions)
struct SettingsRowButton: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let destructive: Bool
let action: () -> Void
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
destructive: Bool = false,
action: @escaping () -> Void
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.destructive = destructive
self.action = action
}
var body: some View {
Button(action: action) {
SettingsRowLabel(
icon: icon,
iconColor: destructive ? .red : iconColor,
title: title,
subtitle: subtitle,
titleColor: destructive ? .red : .primary
)
}
}
}
// MARK: - Settings Row with Picker
struct SettingsRowPicker<T: Hashable>: View {
let icon: String?
let iconColor: Color
let title: String
let selection: Binding<T>
let options: [(value: T, label: String)]
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
selection: Binding<T>,
options: [(value: T, label: String)]
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.selection = selection
self.options = options
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: nil
)
Spacer()
Picker("", selection: selection) {
ForEach(options, id: \.value) { option in
Text(option.label).tag(option.value)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
}
}
// MARK: - Settings Row Label (internal component)
struct SettingsRowLabel: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let titleColor: Color
init(
icon: String?,
iconColor: Color,
title: String,
subtitle: String?,
titleColor: Color = .primary
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.titleColor = titleColor
}
var body: some View {
HStack(spacing: 12) {
if let icon = icon {
Image(systemName: icon)
.foregroundColor(iconColor)
.frame(width: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.foregroundColor(titleColor)
if let subtitle = subtitle {
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Previews
#Preview("Navigation Link") {
List {
SettingsRowNavigationLink(
icon: "paintbrush",
title: "App Icon",
subtitle: nil
) {
Text("Detail View")
}
}
.listStyle(.insetGrouped)
}
#Preview("Toggle") {
List {
SettingsRowToggle(
icon: "speaker.wave.2",
title: "Read Aloud Feature",
subtitle: "Text-to-Speech functionality",
isOn: .constant(true)
)
}
.listStyle(.insetGrouped)
}
#Preview("Value Display") {
List {
SettingsRowValue(
icon: "paintbrush.fill",
iconColor: .purple,
title: "Tint Color",
value: "Purple"
)
}
.listStyle(.insetGrouped)
}
#Preview("Button") {
List {
SettingsRowButton(
icon: "trash",
iconColor: .red,
title: "Clear Cache",
subtitle: "Remove all cached images",
destructive: true
) {
print("Clear cache tapped")
}
}
.listStyle(.insetGrouped)
}
#Preview("Picker") {
List {
SettingsRowPicker(
icon: "textformat",
title: "Font Family",
selection: .constant("System"),
options: [
("System", "System"),
("Serif", "Serif"),
("Monospace", "Monospace")
]
)
}
.listStyle(.insetGrouped)
}

View File

@ -0,0 +1,176 @@
import SwiftUI
struct SkeletonLoadingView: View {
let layout: CardLayoutStyle
@State private var animateGradient = false
var body: some View {
LazyVStack(spacing: layout == .compact ? 8 : 12) {
ForEach(0..<6, id: \.self) { _ in
skeletonCard
}
}
.onAppear {
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
animateGradient = true
}
}
}
@ViewBuilder
private var skeletonCard: some View {
switch layout {
case .compact:
compactSkeletonCard
case .magazine:
magazineSkeletonCard
case .natural:
naturalSkeletonCard
}
}
private var compactSkeletonCard: some View {
HStack(alignment: .top, spacing: 12) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(width: 80, height: 80)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 180, height: 16)
// Description placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 120, height: 14)
Spacer()
// Bottom info placeholder
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 80, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 50, height: 12)
}
}
}
.padding(12)
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var magazineSkeletonCard: some View {
VStack(alignment: .leading, spacing: 8) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(height: 140)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 16)
// Info placeholders
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 80, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 60, height: 12)
}
.padding(.top, 4)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
}
private var naturalSkeletonCard: some View {
VStack(alignment: .leading, spacing: 8) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(minHeight: 180)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 220, height: 16)
// Info placeholders
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 90, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 70, height: 12)
}
.padding(.top, 4)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.3),
Color.gray.opacity(0.1),
Color.gray.opacity(0.3)
],
startPoint: animateGradient ? .topLeading : .topTrailing,
endPoint: animateGradient ? .bottomTrailing : .bottomLeading
)
}
}
#Preview {
ScrollView {
SkeletonLoadingView(layout: .magazine)
.padding()
}
}

View File

@ -0,0 +1,68 @@
import SwiftUI
struct UndoToastView: View {
let bookmarkTitle: String
let progress: Double
let onUndo: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "trash")
.foregroundColor(.white)
.font(.system(size: 16, weight: .medium))
VStack(alignment: .leading, spacing: 4) {
Text("Bookmark deleted")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.white)
Text(bookmarkTitle)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
Button("Undo") {
onUndo()
}
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.white.opacity(0.2))
.clipShape(Capsule())
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.black.opacity(0.85))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
// Progress bar at bottom
VStack {
Spacer()
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle(tint: .white.opacity(0.8)))
.scaleEffect(y: 0.5)
}
)
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
}
}
#Preview {
VStack {
Spacer()
UndoToastView(
bookmarkTitle: "How to Build Great Products",
progress: 0.6,
onUndo: {}
)
.padding()
}
.background(Color.gray.opacity(0.3))
}

View File

@ -6,6 +6,9 @@ struct WebView: UIViewRepresentable {
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
var onScrollToPosition: ((CGFloat) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView {
@ -21,31 +24,50 @@ struct WebView: UIViewRepresentable {
webView.scrollView.isScrollEnabled = false
webView.isOpaque = false
webView.backgroundColor = UIColor.clear
// Allow text selection and copying
webView.allowsBackForwardNavigationGestures = false
webView.allowsLinkPreview = true
// Message Handler hier einmalig hinzufügen
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
webView.configuration.userContentController.add(context.coordinator, name: "scrollToPosition")
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.onScrollToPosition = onScrollToPosition
context.coordinator.webView = webView
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.onScrollToPosition = onScrollToPosition
let isDarkMode = colorScheme == .dark
// Font Settings aus Settings-Objekt
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
// Clean up problematic HTML that kills performance
let cleanedHTML = htmlContent
// Remove Google attributes that cause navigation events
.replacingOccurrences(of: #"\s*jsaction="[^"]*""#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"\s*jscontroller="[^"]*""#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"\s*jsname="[^"]*""#, with: "", options: .regularExpression)
// Remove unnecessary IDs that bloat the DOM
.replacingOccurrences(of: #"\s*id="[^"]*""#, with: "", options: .regularExpression)
// Remove tabindex from non-interactive elements
.replacingOccurrences(of: #"\s*tabindex="[^"]*""#, with: "", options: .regularExpression)
// Remove role=button from figures (causes false click targets)
.replacingOccurrences(of: #"\s*role="button""#, with: "", options: .regularExpression)
// Fix invalid nested <p> tags inside <pre><span>
.replacingOccurrences(of: #"<pre><span[^>]*>([^<]*)<p>"#, with: "<pre><span>$1\n", options: .regularExpression)
.replacingOccurrences(of: #"</p>([^<]*)</span></pre>"#, with: "\n$1</span></pre>", options: .regularExpression)
let styledHTML = """
<html>
<head>
@ -223,30 +245,84 @@ struct WebView: UIViewRepresentable {
--separator-color: #e0e0e0;
}
}
/* Annotation Highlighting - for rd-annotation tags */
rd-annotation {
border-radius: 3px;
padding: 2px 0;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
/* Yellow annotations */
rd-annotation[data-annotation-color="yellow"] {
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="yellow"].selected {
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
}
/* Green annotations */
rd-annotation[data-annotation-color="green"] {
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="green"].selected {
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
}
/* Blue annotations */
rd-annotation[data-annotation-color="blue"] {
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="blue"].selected {
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
}
/* Red annotations */
rd-annotation[data-annotation-color="red"] {
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="red"].selected {
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
}
</style>
</head>
<body>
\(htmlContent)
\(cleanedHTML)
<script>
let lastHeight = 0;
let heightUpdateTimeout = null;
let scrollTimeout = null;
let isScrolling = false;
function updateHeight() {
const height = document.body.scrollHeight;
window.webkit.messageHandlers.heightUpdate.postMessage(height);
if (Math.abs(height - lastHeight) > 5 && !isScrolling) {
lastHeight = height;
window.webkit.messageHandlers.heightUpdate.postMessage(height);
}
}
function debouncedHeightUpdate() {
clearTimeout(heightUpdateTimeout);
heightUpdateTimeout = setTimeout(updateHeight, 100);
}
window.addEventListener('load', updateHeight);
setTimeout(updateHeight, 500);
// 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);
img.addEventListener('load', debouncedHeightUpdate);
});
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</script>
</body>
</html>
@ -254,6 +330,17 @@ struct WebView: UIViewRepresentable {
webView.loadHTMLString(styledHTML, baseURL: nil)
}
func dismantleUIView(_ webView: WKWebView, coordinator: WebViewCoordinator) {
webView.stopLoading()
webView.navigationDelegate = nil
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition")
webView.loadHTMLString("", baseURL: nil)
coordinator.cleanup()
}
func makeCoordinator() -> WebViewCoordinator {
WebViewCoordinator()
}
@ -279,12 +366,321 @@ struct WebView: UIViewRepresentable {
return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace"
}
}
private func generateScrollToAnnotationJS() -> String {
guard let selectedId = selectedAnnotationId else {
return ""
}
return """
// Scroll to selected annotation and add selected class
function scrollToAnnotation() {
// Remove 'selected' class from all annotations
document.querySelectorAll('rd-annotation.selected').forEach(el => {
el.classList.remove('selected');
});
// Find and highlight selected annotation
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) {
selectedElement.classList.add('selected');
// Get the element's position relative to the document
const rect = selectedElement.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elementTop = rect.top + scrollTop;
// Send position to Swift
setTimeout(() => {
window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop);
}, 100);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
} else {
setTimeout(scrollToAnnotation, 300);
}
"""
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
let yellowColor = AnnotationColor.yellow.cssColor(isDark: isDarkMode)
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
return """
// Create annotation color overlay
(function() {
let currentSelection = null;
let currentRange = null;
let selectionTimeout = null;
// Create overlay container with arrow
const overlay = document.createElement('div');
overlay.id = 'annotation-overlay';
overlay.style.cssText = `
display: none;
position: absolute;
z-index: 10000;
`;
// Create arrow/triangle pointing up with glass effect
const arrow = document.createElement('div');
arrow.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
border-bottom: none;
top: -11px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
overlay.appendChild(arrow);
// Create the actual content container with glass morphism effect
const content = document.createElement('div');
content.style.cssText = `
display: flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
gap: 12px;
flex-direction: row;
align-items: center;
`;
overlay.appendChild(content);
// Add "Markierung" label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.style.cssText = `
color: black;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
`;
content.appendChild(label);
// Create color buttons with solid colors
const colors = [
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
];
colors.forEach(({ name, color }) => {
const btn = document.createElement('button');
btn.dataset.color = name;
btn.style.cssText = `
width: 40px;
height: 40px;
border-radius: 50%;
background: ${color};
border: 3px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0;
margin: 0;
transition: transform 0.2s, border-color 0.2s;
`;
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
});
btn.addEventListener('click', () => handleColorSelection(name));
content.appendChild(btn);
});
document.body.appendChild(overlay);
// Selection change listener
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 0) {
currentSelection = text;
currentRange = selection.getRangeAt(0).cloneRange();
showOverlay(selection.getRangeAt(0));
} else {
hideOverlay();
}
}, 150);
});
function showOverlay(range) {
const rect = range.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
overlay.style.display = 'block';
// Center horizontally under selection
const overlayWidth = 320; // Approximate width with label + 4 buttons
const centerX = rect.left + (rect.width / 2);
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
// Position with extra space below selection (55px instead of 70px) to bring it closer
const topPos = rect.bottom + scrollY + 55;
overlay.style.left = leftPos + 'px';
overlay.style.top = topPos + 'px';
}
function hideOverlay() {
overlay.style.display = 'none';
currentSelection = null;
currentRange = null;
}
function calculateOffset(container, offset) {
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(container, offset);
return preRange.toString().length;
}
function getXPathSelector(node) {
// If node is text node, use parent element
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (!element || element === document.body) return 'body';
const path = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
// Count position among siblings of same tag (1-based index)
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
// Format: tagname[index] (1-based)
path.unshift(tagName + '[' + index + ']');
current = current.parentElement;
}
const selector = path.join('/');
console.log('Generated selector:', selector);
return selector || 'body';
}
function calculateOffsetInElement(container, offset) {
// Calculate offset relative to the parent element (not document.body)
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (!element) return offset;
// Create range from start of element to the position
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(container, offset);
return range.toString().length;
}
function generateTempId() {
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function handleColorSelection(color) {
if (!currentRange || !currentSelection) return;
// Generate XPath-like selectors for start and end containers
const startSelector = getXPathSelector(currentRange.startContainer);
const endSelector = getXPathSelector(currentRange.endContainer);
// Calculate offsets relative to the element (not document.body)
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
// Create annotation element
const annotation = document.createElement('rd-annotation');
annotation.setAttribute('data-annotation-color', color);
annotation.setAttribute('data-annotation-id-value', generateTempId());
// Wrap selection in annotation
try {
currentRange.surroundContents(annotation);
} catch (e) {
// If surroundContents fails (e.g., partial element selection), extract and wrap
const fragment = currentRange.extractContents();
annotation.appendChild(fragment);
currentRange.insertNode(annotation);
}
// Send to Swift with selectors
window.webkit.messageHandlers.annotationCreated.postMessage({
color: color,
text: currentSelection,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
});
// Clear selection and hide overlay
window.getSelection().removeAllRanges();
hideOverlay();
}
})();
"""
}
}
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
// Callbacks
var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)?
var hasHeightUpdate: Bool = false
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
var onScrollToPosition: ((CGFloat) -> Void)?
// WebView reference
weak var webView: WKWebView?
// Height management
var lastHeight: CGFloat = 0
var pendingHeight: CGFloat = 0
var heightUpdateTimer: Timer?
// Scroll management
var isScrolling: Bool = false
var scrollVelocity: Double = 0
var lastScrollTime: Date = Date()
var scrollEndTimer: Timer?
// Lifecycle
private var isCleanedUp = false
deinit {
cleanup()
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
@ -300,16 +696,106 @@ 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 {
if self.hasHeightUpdate == false {
self.onHeightChange?(height)
self.hasHeightUpdate = true
}
self.handleHeightUpdate(height: height)
}
}
if message.name == "scrollProgress", let progress = message.body as? Double {
DispatchQueue.main.async {
self.onScroll?(progress)
self.handleScrollProgress(progress: progress)
}
}
if message.name == "annotationCreated", let body = message.body as? [String: Any],
let color = body["color"] as? String,
let text = body["text"] as? String,
let startOffset = body["startOffset"] as? Int,
let endOffset = body["endOffset"] as? Int,
let startSelector = body["startSelector"] as? String,
let endSelector = body["endSelector"] as? String {
DispatchQueue.main.async {
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
}
}
if message.name == "scrollToPosition", let position = message.body as? Double {
DispatchQueue.main.async {
self.onScrollToPosition?(CGFloat(position))
}
}
}
private func handleHeightUpdate(height: CGFloat) {
// Store the pending height
pendingHeight = height
// If we're actively scrolling, defer the height update
if isScrolling {
return
}
// Apply height update immediately if not scrolling
applyHeightUpdate(height: height)
}
private func handleScrollProgress(progress: Double) {
let now = Date()
let timeDelta = now.timeIntervalSince(lastScrollTime)
// Calculate scroll velocity to detect fast scrolling
if timeDelta > 0 {
scrollVelocity = abs(progress) / timeDelta
}
lastScrollTime = now
isScrolling = true
// Longer delay for scroll end detection, especially during fast scrolling
let scrollEndDelay: TimeInterval = scrollVelocity > 2.0 ? 0.8 : 0.5
scrollEndTimer?.invalidate()
scrollEndTimer = Timer.scheduledTimer(withTimeInterval: scrollEndDelay, repeats: false) { [weak self] _ in
self?.handleScrollEnd()
}
onScroll?(progress)
}
private func handleScrollEnd() {
isScrolling = false
scrollVelocity = 0
// Apply any pending height update after scrolling ends
if pendingHeight != lastHeight && pendingHeight > 0 {
// Add small delay to ensure scroll has fully stopped
heightUpdateTimer?.invalidate()
heightUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in
guard let self = self else { return }
self.applyHeightUpdate(height: self.pendingHeight)
}
}
}
private func applyHeightUpdate(height: CGFloat) {
// Only update if height actually changed significantly
let heightDifference = abs(height - lastHeight)
if heightDifference < 5 { // Ignore tiny height changes that cause flicker
return
}
lastHeight = height
onHeightChange?(height)
}
func cleanup() {
guard !isCleanedUp else { return }
isCleanedUp = true
scrollEndTimer?.invalidate()
scrollEndTimer = nil
heightUpdateTimer?.invalidate()
heightUpdateTimer = nil
onHeightChange = nil
onScroll = nil
onAnnotationCreated = nil
onScrollToPosition = nil
}
}

View File

@ -0,0 +1,14 @@
//
// FontSizeExtension.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
import SwiftUI
extension FontSize {
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -16,8 +16,15 @@ protocol UseCaseFactory {
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> PGetLabelsUseCase
func makeCreateLabelUseCase() -> PCreateLabelUseCase
func makeSyncTagsUseCase() -> PSyncTagsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
}
@ -28,9 +35,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
private let settingsRepository: PSettingsRepository = SettingsRepository()
private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider)
private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient)
private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api)
static let shared = DefaultUseCaseFactory()
private init() {}
func makeLoginUseCase() -> PLoginUseCase {
@ -94,7 +104,19 @@ class DefaultUseCaseFactory: UseCaseFactory {
let labelsRepository = LabelsRepository(api: api)
return GetLabelsUseCase(labelsRepository: labelsRepository)
}
func makeCreateLabelUseCase() -> PCreateLabelUseCase {
let api = API(tokenProvider: KeychainTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return CreateLabelUseCase(labelsRepository: labelsRepository)
}
func makeSyncTagsUseCase() -> PSyncTagsUseCase {
let api = API(tokenProvider: KeychainTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return SyncTagsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase()
}
@ -102,4 +124,24 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
return OfflineBookmarkSyncUseCase()
}
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
return LoadCardLayoutUseCase(settingsRepository: settingsRepository)
}
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
}
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase {
return CheckServerReachabilityUseCase(repository: serverInfoRepository)
}
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
}
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
return DeleteAnnotationUseCase(repository: annotationsRepository)
}
}

View File

@ -9,6 +9,10 @@ import Foundation
import Combine
class MockUseCaseFactory: UseCaseFactory {
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
MockCheckServerReachabilityUseCase()
}
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
MockOfflineBookmarkSyncUseCase()
}
@ -72,11 +76,34 @@ class MockUseCaseFactory: UseCaseFactory {
func makeGetLabelsUseCase() -> any PGetLabelsUseCase {
MockGetLabelsUseCase()
}
func makeCreateLabelUseCase() -> any PCreateLabelUseCase {
MockCreateLabelUseCase()
}
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
MockSyncTagsUseCase()
}
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
MockAddTextToSpeechQueueUseCase()
}
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
MockLoadCardLayoutUseCase()
}
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
MockSaveCardLayoutUseCase()
}
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
MockGetBookmarkAnnotationsUseCase()
}
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
MockDeleteAnnotationUseCase()
}
}
@ -106,6 +133,18 @@ class MockGetLabelsUseCase: PGetLabelsUseCase {
}
}
class MockCreateLabelUseCase: PCreateLabelUseCase {
func execute(name: String) async throws {
// Mock implementation - does nothing
}
}
class MockSyncTagsUseCase: PSyncTagsUseCase {
func execute() async throws {
// Mock implementation - does nothing
}
}
class MockSearchBookmarksUseCase: PSearchBookmarksUseCase {
func execute(search: String) async throws -> BookmarksPage {
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)
@ -143,6 +182,7 @@ class MockSaveSettingsUseCase: PSaveSettingsUseCase {
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {}
func execute(enableTTS: Bool) async throws {}
func execute(theme: Theme) async throws {}
func execute(urlOpener: UrlOpener) async throws {}
}
class MockGetBookmarkUseCase: PGetBookmarkUseCase {
@ -204,6 +244,42 @@ class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
}
}
class MockLoadCardLayoutUseCase: PLoadCardLayoutUseCase {
func execute() async -> CardLayoutStyle {
return .magazine
}
}
class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
func execute(layout: CardLayoutStyle) async {
// Mock implementation - do nothing
}
}
class MockCheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
func execute() async -> Bool {
return true
}
func getServerInfo() async throws -> ServerInfo {
return ServerInfo(version: "1.0.0", buildDate: nil, userAgent: nil, isReachable: true)
}
}
class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
func execute(bookmarkId: String) async throws -> [Annotation] {
return [
.init(id: "1", text: "bla", created: "", startOffset: 0, endOffset: 1, startSelector: "", endSelector: "")
]
}
}
class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase {
func execute(bookmarkId: String, annotationId: String) async throws {
// Mock implementation - do nothing
}
}
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

@ -5,6 +5,8 @@
// Created by Ilyas Hallak on 01.07.25.
//
import Foundation
enum BookmarkState: String, CaseIterable {
case all = "all"
case unread = "unread"
@ -14,13 +16,13 @@ enum BookmarkState: String, CaseIterable {
var displayName: String {
switch self {
case .all:
return "All"
return NSLocalizedString("All", comment: "")
case .unread:
return "Unread"
return NSLocalizedString("Unread", comment: "")
case .favorite:
return "Favorites"
return NSLocalizedString("Favorites", comment: "")
case .archived:
return "Archive"
return NSLocalizedString("Archive", comment: "")
}
}

View File

@ -11,8 +11,8 @@ class OfflineBookmarksViewModel {
private let successDelaySubject = PassthroughSubject<Int, Never>()
private var completionTimerActive = false
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
self.syncUseCase = syncUseCase
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase()
setupBindings()
refreshState()
}

View File

@ -13,7 +13,7 @@ struct PadSidebarView: View {
@State private var selectedTag: BookmarkLabel?
@EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
@ -87,11 +87,11 @@ struct PadSidebarView: View {
case .all:
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .unread:
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .favorite:
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .archived:
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .settings:
SettingsView()
case .article:
@ -103,12 +103,12 @@ struct PadSidebarView: View {
case .tags:
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
}
}
.navigationDestination(item: $selectedTag) { label in
BookmarksView(state: .all, type: [], selectedBookmark: $selectedBookmark, tag: label.name)
.navigationTitle("\(label.name) (\(label.count))")
.onDisappear {
selectedTag = nil
}
}
}

View File

@ -9,61 +9,184 @@ import SwiftUI
struct PhoneTabView: View {
private let mainTabs: [SidebarTab] = [.all, .unread, .favorite, .archived]
private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings]
@State private var selectedMoreTab: SidebarTab? = nil
@State private var selectedTabIndex: Int = 1
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
@State private var selectedTab: SidebarTab = .unread
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
// Navigation paths for each tab
@State private var allPath = NavigationPath()
@State private var unreadPath = NavigationPath()
@State private var favoritePath = NavigationPath()
@State private var archivedPath = NavigationPath()
@State private var morePath = NavigationPath()
// Search functionality
@State private var searchViewModel = SearchBookmarksViewModel()
@FocusState private var searchFieldIsFocused: Bool
@EnvironmentObject var appSettings: AppSettings
private var cardLayoutStyle: CardLayoutStyle {
appSettings.settings?.cardLayoutStyle ?? .compact
}
private var offlineBookmarksBadgeCount: Int {
offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0
}
var body: some View {
GlobalPlayerContainerView {
TabView(selection: $selectedTabIndex) {
mainTabsContent
moreTabContent
TabView(selection: $selectedTab) {
Tab(value: SidebarTab.all) {
NavigationStack(path: $allPath) {
tabView(for: .all)
}
} label: {
Label(SidebarTab.all.label, systemImage: SidebarTab.all.systemImage)
}
Tab(value: SidebarTab.unread) {
NavigationStack(path: $unreadPath) {
tabView(for: .unread)
}
} label: {
Label(SidebarTab.unread.label, systemImage: SidebarTab.unread.systemImage)
}
Tab(value: SidebarTab.favorite) {
NavigationStack(path: $favoritePath) {
tabView(for: .favorite)
}
} label: {
Label(SidebarTab.favorite.label, systemImage: SidebarTab.favorite.systemImage)
}
Tab(value: SidebarTab.archived) {
NavigationStack(path: $archivedPath) {
tabView(for: .archived)
}
} label: {
Label(SidebarTab.archived.label, systemImage: SidebarTab.archived.systemImage)
}
// iOS 26+: Dedicated search tab with role
if #available(iOS 26, *) {
Tab("Search", systemImage: SidebarTab.search.systemImage, value: SidebarTab.search, role: .search) {
NavigationStack {
moreTabContent
.searchable(text: $searchViewModel.searchQuery, prompt: "Search bookmarks...")
}
}
.badge(offlineBookmarksBadgeCount)
} else {
Tab(value: SidebarTab.settings) {
NavigationStack(path: $morePath) {
VStack(spacing: 0) {
// Classic search bar for iOS 18
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("Search...", text: $searchViewModel.searchQuery)
.focused($searchFieldIsFocused)
.textFieldStyle(PlainTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
if !searchViewModel.searchQuery.isEmpty {
Button(action: {
searchViewModel.searchQuery = ""
searchFieldIsFocused = true
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
.buttonStyle(.plain)
}
}
.padding(10)
.background(Color(.systemGray6))
.cornerRadius(12)
.padding([.horizontal, .top])
moreTabContent
moreTabsFooter
}
.navigationTitle("More")
}
} label: {
Label("More", systemImage: "ellipsis")
}
.badge(offlineBookmarksBadgeCount)
}
}
.tabBarMinimizeBehaviorIfAvailable()
.accentColor(.accentColor)
.searchToolbarBehaviorIfAvailable()
}
}
// MARK: - Tab Content
@ViewBuilder
private var mainTabsContent: some View {
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
NavigationStack {
tabView(for: tab)
}
.tabItem {
Label(tab.label, systemImage: tab.systemImage)
}
.tag(idx)
}
}
@ViewBuilder
private var moreTabContent: some View {
NavigationStack {
VStack(spacing: 0) {
moreTabsList
moreTabsFooter
}
}
.tabItem {
Label("More", systemImage: "ellipsis")
}
.badge(offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0)
.tag(mainTabs.count)
.onAppear {
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
selectedMoreTab = nil
}
if searchViewModel.searchQuery.isEmpty {
moreTabsList
} else {
searchResultsView
}
}
@ViewBuilder
private var searchResultsView: some View {
if searchViewModel.isLoading {
ProgressView("Searching...")
.padding()
} else if let error = searchViewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.padding()
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
List(bookmarks) { bookmark in
ZStack {
// Hidden NavigationLink to remove disclosure indicator
NavigationLink {
BookmarkDetailView(bookmarkId: bookmark.id)
} label: {
EmptyView()
}
.opacity(0)
BookmarkCardView(
bookmark: bookmark,
currentState: .all,
layout: cardLayoutStyle,
onArchive: { _ in },
onDelete: { _ in },
onToggleFavorite: { _ in }
)
.contentShape(Rectangle())
}
.listRowInsets(EdgeInsets(
top: cardLayoutStyle == .compact ? 8 : 12,
leading: 16,
bottom: cardLayoutStyle == .compact ? 8 : 12,
trailing: 16
))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
}
.scrollContentBackground(.hidden)
.background(Color(R.color.bookmark_list_bg))
.listStyle(.plain)
} else if searchViewModel.searchQuery.isEmpty == false {
ContentUnavailableView("No results", systemImage: "magnifyingglass", description: Text("No bookmarks found."))
.padding()
}
}
@ViewBuilder
private var moreTabsList: some View {
List {
@ -71,12 +194,7 @@ struct PhoneTabView: View {
NavigationLink {
tabView(for: tab)
.navigationTitle(tab.label)
.onDisappear {
// tags and search handle navigation by own
if tab != .tags && tab != .search {
selectedMoreTab = nil
}
}
.navigationBarTitleDisplayMode(.large)
} label: {
Label(tab.label, systemImage: tab.systemImage)
}
@ -116,13 +234,13 @@ struct PhoneTabView: View {
case .all:
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .unread:
BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil))
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .favorite:
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .archived:
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .search:
SearchBookmarksView(selectedBookmark: .constant(nil))
EmptyView() // search is directly implemented
case .settings:
SettingsView()
case .article:
@ -136,3 +254,28 @@ struct PhoneTabView: View {
}
}
}
// MARK: - View Extension for iOS 26+ Compatibility
extension View {
@ViewBuilder
func searchToolbarBehaviorIfAvailable() -> some View {
if #available(iOS 26, *) {
self
.searchToolbarBehavior(.minimize)
} else {
self
}
}
@ViewBuilder
func tabBarMinimizeBehaviorIfAvailable() -> some View {
if #available(iOS 26.0, *) {
self
.tabBarMinimizeBehavior(.onScrollDown)
} else {
self
}
}
}

View File

@ -5,6 +5,8 @@
// Created by Ilyas Hallak on 01.07.25.
//
import Foundation
enum SidebarTab: Hashable, CaseIterable, Identifiable {
case search, all, unread, favorite, archived, article, videos, pictures, tags, settings
@ -12,16 +14,16 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
var label: String {
switch self {
case .all: return "All"
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 "Pictures"
case .tags: return "Tags"
case .all: return NSLocalizedString("All", comment: "")
case .unread: return NSLocalizedString("Unread", comment: "")
case .favorite: return NSLocalizedString("Favorites", comment: "")
case .archived: return NSLocalizedString("Archive", comment: "")
case .search: return NSLocalizedString("Search", comment: "")
case .settings: return NSLocalizedString("Settings", comment: "")
case .article: return NSLocalizedString("Articles", comment: "")
case .videos: return NSLocalizedString("Videos", comment: "")
case .pictures: return NSLocalizedString("Pictures", comment: "")
case .tags: return NSLocalizedString("Tags", comment: "")
}
}

View File

@ -5,21 +5,37 @@ struct MainTabView: View {
@State private var selectedTab: SidebarTab = .unread
@State var selectedBookmark: Bookmark?
@StateObject private var playerUIState = PlayerUIState()
@State private var showReleaseNotes = false
// sizeClass
@Environment(\.horizontalSizeClass)
var horizontalSizeClass
@Environment(\.verticalSizeClass)
var verticalSizeClass
var body: some View {
if UIDevice.isPhone {
PhoneTabView()
.environmentObject(playerUIState)
} else {
PadSidebarView()
.environmentObject(playerUIState)
Group {
if UIDevice.isPhone {
PhoneTabView()
.environmentObject(playerUIState)
} else {
PadSidebarView()
.environmentObject(playerUIState)
}
}
.sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView()
}
.onAppear {
checkForNewVersion()
}
}
private func checkForNewVersion() {
if VersionManager.shared.isNewVersion {
showReleaseNotes = true
VersionManager.shared.markVersionAsSeen()
}
}
}

View File

@ -18,15 +18,23 @@ import Combine
class AppSettings: ObservableObject {
@Published var settings: Settings?
var enableTTS: Bool {
settings?.enableTTS ?? false
}
var theme: Theme {
settings?.theme ?? .system
}
var urlOpener: UrlOpener {
settings?.urlOpener ?? .inAppBrowser
}
var tagSortOrder: TagSortOrder {
settings?.tagSortOrder ?? .byCount
}
init(settings: Settings? = nil) {
self.settings = settings
}

View File

@ -0,0 +1,175 @@
//
// OnboardingServerView.swift
// readeck
//
// Created by Ilyas Hallak on 31.10.25.
//
import SwiftUI
struct OnboardingServerView: View {
@State private var viewModel = SettingsServerViewModel()
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Server Settings".localized, icon: "server.rack")
.padding(.bottom, 4)
Text("Enter your Readeck server details to get started.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
// Form
VStack(spacing: 16) {
// Server Endpoint
VStack(alignment: .leading, spacing: 8) {
TextField("",
text: $viewModel.endpoint,
prompt: Text("Server Endpoint").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.endpoint) {
viewModel.clearMessages()
}
// Quick Input Chips
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
QuickInputChip(text: "http://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "http://" + viewModel.endpoint
}
})
QuickInputChip(text: "https://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "https://" + viewModel.endpoint
}
})
QuickInputChip(text: "192.168.", action: {
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
if viewModel.endpoint.starts(with: "http") {
viewModel.endpoint += "192.168."
} else {
viewModel.endpoint = "http://192.168."
}
}
})
QuickInputChip(text: ":8000", action: {
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
viewModel.endpoint += ":8000"
}
})
}
.padding(.horizontal, 1)
}
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Username
VStack(alignment: .leading, spacing: 8) {
TextField("",
text: $viewModel.username,
prompt: Text("Username").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.username) {
viewModel.clearMessages()
}
}
// Password
VStack(alignment: .leading, spacing: 8) {
SecureField("",
text: $viewModel.password,
prompt: Text("Password").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.password) {
viewModel.clearMessages()
}
}
}
// Messages
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
}
}
VStack(spacing: 10) {
Button(action: {
Task {
await viewModel.saveServerSettings()
}
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canLogin || viewModel.isLoading)
}
}
.task {
await viewModel.loadServerSettings()
}
}
}
// MARK: - Quick Input Chip Component
struct QuickInputChip: View {
let text: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(text)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(12)
}
}
}
#Preview {
OnboardingServerView()
.padding()
}

View File

@ -0,0 +1,134 @@
# Release Notes
Thanks for using the Readeck iOS app! Below are the release notes for each version.
**AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features.
## Version 1.2.0
### Annotations & Highlighting
- **Highlight important passages** directly in your articles
- Select text to bring up a beautiful color picker overlay
- Choose from four distinct colors: yellow, green, blue, and red
- Your highlights are saved and synced across devices
- Tap on annotations in the list to jump directly to that passage in the article
- Glass morphism design for a modern, elegant look
### Performance Improvements
- **Dramatically faster label loading** - especially with 1000+ labels
- Labels now load instantly, even without internet connection
- Share Extension loads much faster
- Better performance when working with many labels
- Improved overall app stability
### Settings Redesign
- **Completely redesigned settings screen** with native iOS style
- Font settings moved to dedicated screen with larger preview
- Reorganized sections for better overview
- Inline explanations directly under settings
- Cleaner app info footer with muted styling
- Combined legal, privacy and support into one section
### Tag Management Improvements
- **Handles 1000+ tags smoothly** - no more lag or slowdowns
- **Tags now load from local database** - no internet required
- Choose your preferred tag sorting: by usage count or alphabetically
- Tags sync automatically in the background
- Share Extension shows your 150 most-used tags instantly
- Better offline support for managing tags
- Faster and more responsive tag selection
### Fixes & Improvements
- Better color consistency throughout the app
- Improved text selection in articles
- Better formatted release notes
- Various bug fixes and stability improvements
---
## Version 1.1.0
There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development.
### Modern Reading Experience (iOS 26+)
- **Completely rebuilt article view** for the latest iOS version
- Smoother scrolling and faster page loading
- Better battery life and memory usage
- Native iOS integration for the best experience
### Quick Actions
- **Smart action buttons** appear automatically when you're almost done reading
- Beautiful, modern design that blends with your content
- Quickly favorite or archive articles without scrolling back up
- Buttons fade away elegantly when you scroll back
- Your progress bar now reflects the entire article length
### Beautiful Article Images
- **Article header images now display properly** without awkward cropping
- Full images with a subtle blurred background
- Tap to view images in full screen
### Smoother Performance
- **Dramatically improved scrolling** - no more stuttering or lag
- Faster article loading times
- Better handling of long articles with many images
- Overall snappier app experience
### Open Links Your Way
- **Choose your preferred browser** for opening links
- Open in Safari or in-app browser
- Thanks to christian-putzke for this contribution!
### Fixes & Improvements
- Articles no longer overflow the screen width
- Fixed spacing issues in article view
- Improved progress calculation accuracy
- Better handling of article content
- Fixed issues with label names containing spaces
---
## Version 1.0 (Initial Release)
### Core Features
- Browse and read saved articles
- Bookmark management with labels
- Full article view with custom fonts
- Text-to-speech support (Beta)
- Archive and favorite functionality
- Choose different Layouts (Compact, Magazine, Natural)
### Reading Experience
- Clean, distraction-free reading interface
- Customizable font settings
- Header Image viewer with zoom support
- Progress tracking per article
- Dark mode support
### Organization
- Label system for categorization (multi-select)
- Search
- Archive completed articles
- Jump to last read position
### Share Extension
- Save articles from other apps
- Quick access to save and label bookmarks
- Save Bookmarks offline if your server is not reachable and sync later

View File

@ -7,6 +7,7 @@ struct SearchBookmarksView: View {
@Binding var selectedBookmark: Bookmark?
@Namespace private var namespace
@State private var isFirstAppearance = true
@State private var cardLayoutStyle: CardLayoutStyle = .magazine
var body: some View {
VStack(spacing: 0) {
@ -61,10 +62,22 @@ struct SearchBookmarksView: View {
}
}
}) {
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }, namespace: namespace)
BookmarkCardView(
bookmark: bookmark,
currentState: .all,
layout: cardLayoutStyle,
onArchive: {_ in },
onDelete: {_ in },
onToggleFavorite: {_ in }
)
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowInsets(EdgeInsets(
top: cardLayoutStyle == .compact ? 8 : 12,
leading: 16,
bottom: cardLayoutStyle == .compact ? 8 : 12,
trailing: 16
))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
}
@ -91,14 +104,29 @@ struct SearchBookmarksView: View {
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
BookmarkDetailView(bookmarkId: bookmarkId)
}
.onAppear {
if isFirstAppearance {
searchFieldIsFocused = true
isFirstAppearance = false
}
loadCardLayoutStyle()
}
.onReceive(NotificationCenter.default.publisher(for: .cardLayoutChanged)) { notification in
if let layout = notification.object as? CardLayoutStyle {
cardLayoutStyle = layout
}
}
}
private func loadCardLayoutStyle() {
Task {
let loadCardLayoutUseCase = DefaultUseCaseFactory.shared.makeLoadCardLayoutUseCase()
let layout = await loadCardLayoutUseCase.execute()
await MainActor.run {
cardLayoutStyle = layout
}
}
}
}

View File

@ -0,0 +1,173 @@
import SwiftUI
struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system
@State private var selectedTagSortOrder: TagSortOrder = .byCount
@State private var fontViewModel: FontSettingsViewModel
@State private var generalViewModel: SettingsGeneralViewModel
@EnvironmentObject private var appSettings: AppSettings
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
private let settingsRepository: PSettingsRepository
init(
factory: UseCaseFactory = DefaultUseCaseFactory.shared,
fontViewModel: FontSettingsViewModel = FontSettingsViewModel(),
generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()
) {
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
self.settingsRepository = SettingsRepository()
self.fontViewModel = fontViewModel
self.generalViewModel = generalViewModel
}
var body: some View {
Group {
Section {
// Font Settings als NavigationLink
NavigationLink {
FontSelectionView(viewModel: fontViewModel)
} label: {
HStack {
Text("Font")
Spacer()
Text("\(fontViewModel.selectedFontFamily.displayName) · \(fontViewModel.selectedFontSize.displayName)")
.foregroundColor(.secondary)
}
}
// Theme Picker (Menu statt Segmented)
Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme)
}
}
.onChange(of: selectedTheme) {
saveThemeSettings()
}
// Card Layout als NavigationLink
NavigationLink {
CardLayoutSelectionView(
selectedCardLayout: $selectedCardLayout,
onSave: saveCardLayoutSettings
)
} label: {
HStack {
Text("Card Layout")
Spacer()
Text(selectedCardLayout.displayName)
.foregroundColor(.secondary)
}
}
// Open external links in
VStack(alignment: .leading, spacing: 4) {
Picker("Open links in", selection: $generalViewModel.urlOpener) {
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
Text(urlOpener.displayName).tag(urlOpener)
}
}
.onChange(of: generalViewModel.urlOpener) {
Task {
await generalViewModel.saveGeneralSettings()
}
}
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
// Tag Sort Order
VStack(alignment: .leading, spacing: 4) {
Picker("Tag sort order", selection: $selectedTagSortOrder) {
ForEach(TagSortOrder.allCases, id: \.self) { sortOrder in
Text(sortOrder.displayName).tag(sortOrder)
}
}
.onChange(of: selectedTagSortOrder) {
saveTagSortOrderSettings()
}
Text("Determines how tags are displayed when adding or editing bookmarks.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
} header: {
Text("Appearance")
}
}
.task {
await fontViewModel.loadFontSettings()
await generalViewModel.loadGeneralSettings()
loadSettings()
}
}
private func loadSettings() {
Task {
// Load theme, card layout, and tag sort order from repository
if let settings = try? await settingsRepository.loadSettings() {
await MainActor.run {
selectedTheme = settings.theme ?? .system
selectedTagSortOrder = settings.tagSortOrder ?? .byCount
}
}
selectedCardLayout = await loadCardLayoutUseCase.execute()
}
}
private func saveThemeSettings() {
Task {
// Load current settings, update theme, and save back
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
settings.theme = selectedTheme
try? await settingsRepository.saveSettings(settings)
// Notify app about theme change
await MainActor.run {
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
}
}
private func saveCardLayoutSettings() {
Task {
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
// Notify other parts of the app about the change
await MainActor.run {
NotificationCenter.default.post(name: .cardLayoutChanged, object: selectedCardLayout)
}
}
}
private func saveTagSortOrderSettings() {
Task {
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
settings.tagSortOrder = selectedTagSortOrder
try? await settingsRepository.saveSettings(settings)
// Update AppSettings to trigger UI updates
await MainActor.run {
appSettings.settings?.tagSortOrder = selectedTagSortOrder
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
}
}
}
#Preview {
NavigationStack {
List {
AppearanceSettingsView()
}
.listStyle(.insetGrouped)
}
}

View File

@ -0,0 +1,137 @@
import SwiftUI
import Kingfisher
struct CacheSettingsView: View {
@State private var cacheSize: String = "0 MB"
@State private var maxCacheSize: Double = 200
@State private var isClearing: Bool = false
@State private var showClearAlert: Bool = false
var body: some View {
Section {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size")
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Refresh") {
updateCacheSize()
}
.font(.caption)
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Max Cache Size")
Spacer()
Text("\(Int(maxCacheSize)) MB")
.font(.caption)
.foregroundColor(.secondary)
}
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
Text("Max Cache Size")
}
.onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue)
}
}
Button(action: {
showClearAlert = true
}) {
HStack {
if isClearing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
}
VStack(alignment: .leading, spacing: 2) {
Text("Clear Cache")
.foregroundColor(isClearing ? .secondary : .red)
Text("Remove all cached images")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
.disabled(isClearing)
} header: {
Text("Cache Settings")
}
.onAppear {
updateCacheSize()
loadMaxCacheSize()
}
.alert("Clear Cache", isPresented: $showClearAlert) {
Button("Cancel", role: .cancel) { }
Button("Clear", role: .destructive) {
clearCache()
}
} message: {
Text("This will remove all cached images. They will be downloaded again when needed.")
}
}
private func updateCacheSize() {
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
DispatchQueue.main.async {
switch result {
case .success(let size):
let mbSize = Double(size) / (1024 * 1024)
self.cacheSize = String(format: "%.1f MB", mbSize)
case .failure:
self.cacheSize = "Unknown"
}
}
}
}
private func loadMaxCacheSize() {
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
if let savedSize = savedSize {
maxCacheSize = Double(savedSize) / (1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize
} else {
maxCacheSize = 200
let defaultBytes = UInt(200 * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
}
}
private func updateMaxCacheSize(_ newSize: Double) {
let bytes = UInt(newSize * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
}
private func clearCache() {
isClearing = true
KingfisherManager.shared.cache.clearDiskCache {
DispatchQueue.main.async {
self.isClearing = false
self.updateCacheSize()
}
}
KingfisherManager.shared.cache.clearMemoryCache()
}
}
#Preview {
List {
CacheSettingsView()
}
.listStyle(.insetGrouped)
}

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