Compare commits

...

52 Commits

Author SHA1 Message Date
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
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
59 changed files with 2811 additions and 643 deletions

3
.gitignore vendored
View File

@ -66,3 +66,6 @@ fastlane/AuthKey_JZJCQWW9N3.p8
# Documentation
documentation/
# macOS
**/.DS_Store

View File

@ -7,24 +7,17 @@ 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.
@ -34,6 +27,29 @@ Please report any bugs, crashes, or suggestions directly through TestFlight, or
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)

View File

@ -208,19 +208,15 @@ struct ShareBookmarkView: View {
}
private func addCustomTag() {
let trimmed = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText)
let availableLabels = viewModel.labels.map { $0.name }
let currentLabels = Array(viewModel.selectedLabels)
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
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 = ""
for label in uniqueLabels {
viewModel.selectedLabels.insert(label)
}
viewModel.searchText = ""
}
}

View File

@ -83,6 +83,7 @@
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,
@ -436,7 +437,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -449,7 +450,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -469,7 +470,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -482,7 +483,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@ -624,7 +625,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -647,7 +648,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.1;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -668,7 +669,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -691,7 +692,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.1;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -241,7 +241,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 {

View File

@ -50,6 +50,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,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

@ -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
@ -17,27 +17,28 @@ class LabelsRepository: PLabelsRepository {
}
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 { [weak self] in
guard let self = self else { return }
for dto in dtos {
if !self.tagExists(name: dto.name, in: backgroundContext) {
dto.toEntity(context: backgroundContext)
}
}
try backgroundContext.save()
}
try coreDataManager.context.save()
}
private func tagExists(name: String) -> Bool {
private func tagExists(name: String, in context: NSManagedObjectContext) -> 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
}
do {
let count = try context.count(for: fetchRequest)
return count > 0
} catch {
return false
}
return exists
}
}

View File

@ -14,6 +14,8 @@ struct Settings {
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var urlOpener: UrlOpener? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
}
@ -91,6 +93,10 @@ 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
}
@ -132,7 +138,8 @@ class SettingsRepository: PSettingsRepository {
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
enableTTS: settingEntity?.enableTTS,
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue)
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue),
urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue)
)
continuation.resume(returning: settings)
} catch {

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

@ -0,0 +1,11 @@
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

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

@ -18,6 +18,34 @@
"%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";

View File

@ -5,4 +5,156 @@
Created by conversion from Localizable.xcstrings
*/
"all" = "Ale";
"" = "";
"(%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-Einstellungen";
"Appearance" = "Darstellung";
"Cache Settings" = "Cache-Einstellungen";
"General Settings" = "Allgemeine Einstellungen";
"Server Settings" = "Server-Einstellungen";
"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";
"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-Einstellungen";
"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-Einstellungen";
"Font size" = "Schriftgröße";
"From Bremen with 💚" = "Aus Bremen mit 💚";
"General" = "Allgemein";
"Global Level" = "Globales Level";
"Global Minimum Level" = "Globales Minimum-Level";
"Global Settings" = "Globale Einstellungen";
"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";
"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

@ -18,6 +18,34 @@
"%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";

View File

@ -0,0 +1,87 @@
# 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.1
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

@ -0,0 +1,551 @@
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 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
}
}
)
.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.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
}
.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: {
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(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.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: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) {
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(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,472 +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
// 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 = 360
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId
self.viewModel = viewModel
self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings
self.showingLabelsSheet = showingLabelsSheet
}
var body: some View {
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, 4)
.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: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
}
.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) {
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
.clipped()
.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,518 @@
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 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: $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: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.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")
}
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.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()
}
}
@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
}
}
)
.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.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
}
.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

@ -73,15 +73,12 @@ class BookmarkLabelsViewModel {
@MainActor
func addLabel(to bookmarkId: String, label: String) async {
let individualLabels = label
.components(separatedBy: " ")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.filter { !currentLabels.contains($0) }
let splitLabels = LabelUtils.splitLabelsFromInput(label)
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels)
guard !individualLabels.isEmpty else { return }
guard !uniqueLabels.isEmpty else { return }
await addLabels(to: bookmarkId, labels: individualLabels)
await addLabels(to: bookmarkId, labels: uniqueLabels)
newLabelText = ""
searchText = ""
}

View File

@ -14,6 +14,7 @@ extension View {
struct BookmarkCardView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var appSettings: AppSettings
let bookmark: Bookmark
let currentState: BookmarkState
@ -255,7 +256,7 @@ struct BookmarkCardView: View {
HStack {
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
.onTapGesture {
SafariUtil.openInSafari(url: bookmark.url)
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
}
}
}
@ -336,7 +337,7 @@ struct BookmarkCardView: View {
HStack {
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
.onTapGesture {
SafariUtil.openInSafari(url: bookmark.url)
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
}
}
}
@ -434,4 +435,4 @@ struct IconBadge: View {
.foregroundColor(.white)
.clipShape(Circle())
}
}
}

View File

@ -56,6 +56,7 @@ struct BookmarksView: View {
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId)
.toolbar(.hidden, for: .tabBar)
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)

View File

@ -0,0 +1,309 @@
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
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
var body: some View {
WebKit.WebView(webPage)
.scrollDisabled(true) // Disable internal scrolling
.onAppear {
loadStyledContent()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
}
.onChange(of: colorScheme) { _, _ 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 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; }
</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();
</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"
}
}
}
// 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

@ -21,31 +21,43 @@ 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")
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
let isDarkMode = colorScheme == .dark
// 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>
@ -226,26 +238,31 @@ struct WebView: UIViewRepresentable {
</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);
});
</script>
</body>
@ -254,6 +271,15 @@ 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.loadHTMLString("", baseURL: nil)
coordinator.cleanup()
}
func makeCoordinator() -> WebViewCoordinator {
WebViewCoordinator()
}
@ -282,9 +308,27 @@ struct WebView: UIViewRepresentable {
}
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
// Callbacks
var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)?
var hasHeightUpdate: Bool = false
// 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 +344,88 @@ 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)
}
}
}
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
}
}

View File

@ -150,6 +150,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 {

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

@ -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,59 +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
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
@State private var selectedTab: SidebarTab = .unread
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
// 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 {
NavigationStack {
GlobalPlayerContainerView {
TabView(selection: $selectedTabIndex) {
mainTabsContent
moreTabContent
GlobalPlayerContainerView {
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)
}
.accentColor(.accentColor)
}
.tabBarMinimizeBehaviorIfAvailable()
.accentColor(.accentColor)
.searchToolbarBehaviorIfAvailable()
}
}
// MARK: - Tab Content
@ViewBuilder
private var mainTabsContent: some View {
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
tabView(for: tab)
.tabItem {
Label(tab.label, systemImage: tab.systemImage)
}
.tag(idx)
}
}
@ViewBuilder
private var moreTabContent: some View {
VStack(spacing: 0) {
if searchViewModel.searchQuery.isEmpty {
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
}
} 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
// Hidden NavigationLink to remove disclosure indicator
// To restore: uncomment block below and remove ZStack
ZStack {
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 {
@ -70,12 +195,6 @@ struct PhoneTabView: View {
tabView(for: tab)
.navigationTitle(tab.label)
.navigationBarTitleDisplayMode(.large)
.onDisappear {
// tags and search handle navigation by own
if tab != .tags && tab != .search {
selectedMoreTab = nil
}
}
} label: {
Label(tab.label, systemImage: tab.systemImage)
}
@ -121,7 +240,7 @@ struct PhoneTabView: View {
case .archived:
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
case .search:
SearchBookmarksView(selectedBookmark: .constant(nil))
EmptyView() // search is directly implemented
case .settings:
SettingsView()
case .article:
@ -135,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

@ -26,6 +26,10 @@ class AppSettings: ObservableObject {
var theme: Theme {
settings?.theme ?? .system
}
var urlOpener: UrlOpener {
settings?.urlOpener ?? .inAppBrowser
}
init(settings: Settings? = nil) {
self.settings = settings

View File

@ -16,7 +16,7 @@ struct AppearanceSettingsView: View {
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Appearance", icon: "paintbrush")
SectionHeader(title: "Appearance".localized, icon: "paintbrush")
.padding(.bottom, 4)
// Theme Section

View File

@ -9,7 +9,7 @@ struct CacheSettingsView: View {
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Cache Settings", icon: "internaldrive")
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
.padding(.bottom, 4)
VStack(spacing: 12) {

View File

@ -16,7 +16,7 @@ struct FontSettingsView: View {
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Font Settings", icon: "textformat")
SectionHeader(title: "Font Settings".localized, icon: "textformat")
.padding(.bottom, 4)
// Font Family Picker

View File

@ -0,0 +1,96 @@
import SwiftUI
struct LegalNoticeView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Legal Notice")
.font(.largeTitle)
.fontWeight(.bold)
.padding(.bottom, 10)
VStack(alignment: .leading, spacing: 16) {
sectionView(
title: "App Publisher",
content: """
Ilyas Hallak
Albert-Bischof-Str. 18
28357 Bremen
Germany
Email: hi@ilyashallak.de
"""
)
sectionView(
title: "Content Responsibility",
content: "The publisher is responsible for the content of this application in accordance with applicable laws."
)
sectionView(
title: "App Information",
content: """
readeck iOS - Bookmark Management Client
Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown")
Build: \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown")
This app is an open source client for readeck bookmark management.
"""
)
sectionView(
title: "License",
content: "This software is released under the MIT License. The source code is available at the official repository."
)
sectionView(
title: "Disclaimer",
content: "The app is provided \"as is\" without warranty of any kind. The publisher assumes no liability for damages arising from the use of this application."
)
// TODO: Add business registration details if needed
// sectionView(
// title: "Business Registration",
// content: """
// [Business Registration Number]
// [Tax ID / VAT Number]
// [Responsible Authority]
// """
// )
}
}
.padding()
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
@ViewBuilder
private func sectionView(title: String, content: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.fontWeight(.semibold)
Text(content)
.font(.body)
.foregroundColor(.primary)
}
.padding(.vertical, 4)
}
}
#Preview {
LegalNoticeView()
}

View File

@ -0,0 +1,125 @@
import SwiftUI
struct LegalPrivacySettingsView: View {
@State private var showingPrivacyPolicy = false
@State private var showingLegalNotice = false
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
.padding(.bottom, 4)
VStack(spacing: 16) {
// Privacy Policy
Button(action: {
showingPrivacyPolicy = true
}) {
HStack {
Text(NSLocalizedString("Privacy Policy", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
// Legal Notice
Button(action: {
showingLegalNotice = true
}) {
HStack {
Text(NSLocalizedString("Legal Notice", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
Divider()
.padding(.vertical, 8)
// Support Section
VStack(spacing: 12) {
// Report an Issue
Button(action: {
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text(NSLocalizedString("Report an Issue", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
// Contact Support
Button(action: {
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text(NSLocalizedString("Contact Support", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
}
.sheet(isPresented: $showingPrivacyPolicy) {
PrivacyPolicyView()
}
.sheet(isPresented: $showingLegalNotice) {
LegalNoticeView()
}
}
}
#Preview {
LegalPrivacySettingsView()
.cardStyle()
.padding()
}

View File

@ -0,0 +1,82 @@
import SwiftUI
struct PrivacyPolicyView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Privacy Policy")
.font(.largeTitle)
.fontWeight(.bold)
.padding(.bottom, 10)
Text("Last updated: September 20, 2025")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 16) {
sectionView(
title: "Data Collection",
content: "readeck iOS does not collect, store, or transmit any personal data. The app operates as a client for your personal readeck server and all data remains on your device or your own server infrastructure."
)
sectionView(
title: "Local Storage",
content: "The app stores bookmarks locally on your device using CoreData for offline access. Login credentials are securely stored in the iOS Keychain. No data is shared with third parties."
)
sectionView(
title: "Server Communication",
content: "The app communicates only with your configured readeck server to synchronize bookmarks. No analytics, tracking, or telemetry data is collected or transmitted."
)
sectionView(
title: "Third-Party Services",
content: "This app does not use any third-party analytics, advertising, or tracking services. It does not integrate with social media platforms or other external services."
)
sectionView(
title: "Your Rights",
content: "Since no personal data is collected, processed, or stored by us, there is no personal data to access, modify, or delete from our side. All your data is under your control on your device and server."
)
sectionView(
title: "Contact",
content: "If you have questions about this privacy policy, please contact us at: hi@ilyashallak.de"
)
}
}
.padding()
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
@ViewBuilder
private func sectionView(title: String, content: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.fontWeight(.semibold)
Text(content)
.font(.body)
.foregroundColor(.primary)
}
.padding(.vertical, 4)
}
}
#Preview {
PrivacyPolicyView()
}

View File

@ -0,0 +1,86 @@
import SwiftUI
extension AttributedString {
init(styledMarkdown markdownString: String) throws {
var output = try AttributedString(
markdown: markdownString,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
),
baseURL: nil
)
for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
guard let intentBlock = intentBlock else { continue }
for intent in intentBlock.components {
switch intent.kind {
case .header(level: let level):
switch level {
case 1:
output[intentRange].font = .system(.title).bold()
case 2:
output[intentRange].font = .system(.title2).bold()
case 3:
output[intentRange].font = .system(.title3).bold()
default:
break
}
default:
break
}
}
if intentRange.lowerBound != output.startIndex {
output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound)
}
}
self = output
}
}
struct ReleaseNotesView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let attributedString = loadReleaseNotes() {
Text(attributedString)
.textSelection(.enabled)
.padding()
} else {
Text("Unable to load release notes")
.foregroundColor(.secondary)
.padding()
}
}
}
.navigationTitle("What's New")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
}
}
private func loadReleaseNotes() -> AttributedString? {
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
let markdownContent = try? String(contentsOf: url),
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
return nil
}
return attributedString
}
}
#Preview {
ReleaseNotesView()
}

View File

@ -33,6 +33,9 @@ struct SettingsContainerView: View {
SettingsServerView()
.cardStyle()
LegalPrivacySettingsView()
.cardStyle()
// Debug-only Logging Configuration
if Bundle.main.isDebugBuild {
debugSettingsSection

View File

@ -8,20 +8,39 @@
import SwiftUI
struct SettingsGeneralView: View {
@State private var viewModel: SettingsGeneralViewModel
@State private var viewModel: SettingsGeneralViewModel
@State private var showReleaseNotes = false
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
self.viewModel = viewModel
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "General Settings", icon: "gear")
SectionHeader(title: "General Settings".localized, icon: "gear")
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 12) {
Text("General")
.font(.headline)
// What's New Button
Button(action: {
showReleaseNotes = true
}) {
HStack {
Label("What's New", systemImage: "sparkles")
Spacer()
Text("Version \(VersionManager.shared.currentVersion)")
.font(.caption)
.foregroundColor(.secondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
.toggleStyle(.switch)
.onChange(of: viewModel.enableTTS) {
@ -33,6 +52,23 @@ struct SettingsGeneralView: View {
.font(.footnote)
}
// Reading Settings
VStack(alignment: .leading, spacing: 12) {
Text("Open external links in".localized)
.font(.headline)
Picker("urlOpener", selection: $viewModel.urlOpener) {
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
Text(urlOpener.displayName.localized).tag(urlOpener)
}
}
.pickerStyle(.segmented)
.onChange(of: viewModel.urlOpener) {
Task {
await viewModel.saveGeneralSettings()
}
}
}
#if DEBUG
// Sync Settings
VStack(alignment: .leading, spacing: 12) {
@ -55,8 +91,6 @@ struct SettingsGeneralView: View {
.font(.headline)
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
.toggleStyle(SwitchToggleStyle())
Toggle("Open external links in in-app Safari", isOn: $viewModel.openExternalLinksInApp)
.toggleStyle(SwitchToggleStyle())
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
.toggleStyle(SwitchToggleStyle())
}
@ -83,6 +117,9 @@ struct SettingsGeneralView: View {
#endif
}
.sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView()
}
.task {
await viewModel.loadGeneralSettings()
}

View File

@ -15,8 +15,8 @@ class SettingsGeneralViewModel {
// MARK: - Reading Settings
var enableReaderMode: Bool = false
var enableTTS: Bool = false
var openExternalLinksInApp: Bool = true
var autoMarkAsRead: Bool = false
var urlOpener: UrlOpener = .inAppBrowser
// MARK: - Messages
@ -36,6 +36,7 @@ class SettingsGeneralViewModel {
if let settings = try await loadSettingsUseCase.execute() {
enableTTS = settings.enableTTS ?? false
selectedTheme = settings.theme ?? .system
urlOpener = settings.urlOpener ?? .inAppBrowser
autoSyncEnabled = false
}
} catch {
@ -48,6 +49,7 @@ class SettingsGeneralViewModel {
do {
try await saveSettingsUseCase.execute(enableTTS: enableTTS)
try await saveSettingsUseCase.execute(theme: selectedTheme)
try await saveSettingsUseCase.execute(urlOpener: urlOpener)
successMessage = "Settings saved"

View File

@ -18,7 +18,7 @@ struct SettingsServerView: View {
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: viewModel.isSetupMode ? "Server Settings" : "Server Connection", icon: "server.rack")
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
.padding(.bottom, 4)
Text(viewModel.isSetupMode ?

View File

@ -1,8 +1,25 @@
import UIKit
import SafariServices
class SafariUtil {
static func openInSafari(url: String) {
struct URLUtil {
static func open(url: String, urlOpener: UrlOpener = .inAppBrowser) {
// Could be extended to open in other browsers like Firefox, Brave etc. if somebody has a multi browser setup
// and wants readeck links to always opened in a specific browser
switch urlOpener {
case .defaultBrowser:
openUrlInDefaultBrowser(url: url)
default:
openUrlInInAppBrowser(url: url)
}
}
static func openUrlInDefaultBrowser(url: String) {
guard let url = URL(string: url) else { return }
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
static func openUrlInInAppBrowser(url: String) {
guard let url = URL(string: url) else { return }
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
@ -22,9 +39,7 @@ class SafariUtil {
presentingViewController.present(safariViewController, animated: true)
}
}
}
struct URLUtil {
static func extractDomain(from urlString: String) -> String? {
guard let url = URL(string: urlString), let host = url.host else { return nil }
return host.replacingOccurrences(of: "www.", with: "")

View File

@ -51,3 +51,58 @@ struct readeckApp: App {
}
}
}
struct TestView: View {
var body: some View {
if #available(iOS 26.0, *) {
Text("hello")
.toolbar {
ToolbarSpacer(.flexible)
ToolbarItem {
Button {
} label: {
Label("Favorite", systemImage: "share")
.symbolVariant(.none)
}
}
ToolbarSpacer(.fixed)
ToolbarItemGroup {
Button {
} label: {
Label("Favorite", systemImage: "heart")
.symbolVariant(.none)
}
Button("Info", systemImage: "info") {
}
}
ToolbarItemGroup(placement: .bottomBar) {
Spacer()
Button {
} label: {
Label("Favorite", systemImage: "heart")
.symbolVariant(.none)
}
Button("Info", systemImage: "info") {
}
}
}
.toolbar(removing: .title)
.ignoresSafeArea(edges: .top)
} else {
Text("hello1")
}
}
}

View File

@ -0,0 +1,38 @@
import Foundation
class VersionManager {
static let shared = VersionManager()
private let lastSeenVersionKey = "lastSeenAppVersion"
private let userDefaults = UserDefaults.standard
var currentVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
}
var currentBuild: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
}
var fullVersion: String {
"\(currentVersion) (\(currentBuild))"
}
var lastSeenVersion: String? {
userDefaults.string(forKey: lastSeenVersionKey)
}
var isNewVersion: Bool {
guard let lastSeen = lastSeenVersion else {
// First launch
return true
}
return lastSeen != currentVersion
}
func markVersionAsSeen() {
userDefaults.set(currentVersion, forKey: lastSeenVersionKey)
}
private init() {}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24G84" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="25A354" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="ArticleURLEntity" representedClassName="ArticleURLEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="tags" optional="YES" attributeType="String"/>
@ -57,6 +57,7 @@
<attribute name="fontSize" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="String"/>
<attribute name="token" optional="YES" attributeType="String"/>
<attribute name="urlOpener" optional="YES" attributeType="String"/>
</entity>
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/>

Binary file not shown.

Binary file not shown.

BIN
screenshots/ipad_1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
screenshots/ipad_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
screenshots/ipad_3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
screenshots/ipad_4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
screenshots/ipad_5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
screenshots/iphone_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
screenshots/iphone_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
screenshots/iphone_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
screenshots/iphone_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
screenshots/iphone_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB