Compare commits

...

12 Commits

Author SHA1 Message Date
d71bb1f6e1 feat: Add iOS 26 native SwiftUI WebView implementation
- Implement automatic version detection for iOS 26+ vs legacy WebView
- Add NativeWebView using SwiftUI WebView and WebPage for iOS 26+
- Maintain LegacyWebView with WKWebView for older iOS versions
- Include JavaScript height calculation with multiple timing strategies
- Add scroll disabling and proper height management
- Implement graceful fallback when JavaScript fails
- Update BookmarkDetailView to use new WebView structure
2025-09-27 22:14:01 +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
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
32 changed files with 411 additions and 67 deletions

3
.gitignore vendored
View File

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

View File

@ -18,9 +18,15 @@ https://codeberg.org/readeck/readeck
<img src="screenshots/ipad.webp" height="400" alt="iPad View"> <img src="screenshots/ipad.webp" height="400" alt="iPad View">
</p> </p>
## TestFlight Beta Access ## Download
You can now join the public TestFlight beta for the Readeck iOS app: ### App Store (Stable Releases)
The official app is available on the App Store with stable, tested releases:
[Download Readeck on the App Store](https://apps.apple.com/de/app/readeck/id6748764703)
### TestFlight Beta Access (Early Releases)
For early access to new features and beta versions (use with caution):
[Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR) [Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR)

View File

@ -83,6 +83,7 @@
Data/CoreData/CoreDataManager.swift, Data/CoreData/CoreDataManager.swift,
"Data/Extensions/NSManagedObjectContext+SafeFetch.swift", "Data/Extensions/NSManagedObjectContext+SafeFetch.swift",
Data/KeychainHelper.swift, Data/KeychainHelper.swift,
Data/Utils/LabelUtils.swift,
Domain/Model/Bookmark.swift, Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift, Domain/Model/BookmarkLabel.swift,
Logger.swift, Logger.swift,
@ -436,7 +437,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist; INFOPLIST_FILE = URLShare/Info.plist;
@ -469,7 +470,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist; INFOPLIST_FILE = URLShare/Info.plist;
@ -624,7 +625,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@ -668,7 +669,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;

View File

@ -50,6 +50,16 @@ class CoreDataManager {
return persistentContainer.viewContext return persistentContainer.viewContext
} }
var mainContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.automaticallyMergesChangesFromParent = true
return context
}
func save() { func save() {
if context.hasChanges { if context.hasChanges {
do { do {

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
import CoreData import CoreData
class LabelsRepository: PLabelsRepository { class LabelsRepository: PLabelsRepository, @unchecked Sendable {
private let api: PAPI private let api: PAPI
private let coreDataManager = CoreDataManager.shared private let coreDataManager = CoreDataManager.shared
@ -17,27 +17,28 @@ class LabelsRepository: PLabelsRepository {
} }
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws { func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
for dto in dtos { let backgroundContext = coreDataManager.newBackgroundContext()
if !tagExists(name: dto.name) {
dto.toEntity(context: coreDataManager.context) 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() let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name) fetchRequest.predicate = NSPredicate(format: "name == %@", name)
var exists = false do {
coreDataManager.context.performAndWait { let count = try context.count(for: fetchRequest)
do { return count > 0
let results = try coreDataManager.context.fetch(fetchRequest) } catch {
exists = !results.isEmpty return false
} catch {
exists = false
}
} }
return exists
} }
} }

View File

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

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

View File

@ -46,6 +46,9 @@
"General Settings" = "Allgemeine Einstellungen"; "General Settings" = "Allgemeine Einstellungen";
"Server Settings" = "Server-Einstellungen"; "Server Settings" = "Server-Einstellungen";
"Server Connection" = "Server-Verbindung"; "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" = "Hinzufügen";
"Add new tag:" = "Neues Label hinzufügen:"; "Add new tag:" = "Neues Label hinzufügen:";

View File

@ -25,10 +25,9 @@ struct BookmarkDetailView: View {
private let headerHeight: CGFloat = 360 private let headerHeight: CGFloat = 360
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) { init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId self.bookmarkId = bookmarkId
self.viewModel = viewModel self.viewModel = viewModel
self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings self.showingFontSettings = showingFontSettings
self.showingLabelsSheet = showingLabelsSheet self.showingLabelsSheet = showingLabelsSheet
} }
@ -61,6 +60,8 @@ struct BookmarkDetailView: View {
if webViewHeight != height { if webViewHeight != height {
webViewHeight = height webViewHeight = height
} }
}, onScroll: { progress in
// Handle scroll progress if needed
}) })
.frame(height: webViewHeight) .frame(height: webViewHeight)
.cornerRadius(14) .cornerRadius(14)
@ -72,7 +73,7 @@ struct BookmarkDetailView: View {
.padding() .padding()
} else { } else {
Button(action: { Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url) URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) { }) {
HStack { HStack {
Image(systemName: "safari") Image(systemName: "safari")
@ -247,23 +248,13 @@ struct BookmarkDetailView: View {
@ViewBuilder @ViewBuilder
private var contentSection: some View { private var contentSection: some View {
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { if viewModel.isLoadingArticle {
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...") ProgressView("Loading article...")
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding() .padding()
} else { } else {
Button(action: { Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url) URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) { }) {
HStack { HStack {
Image(systemName: "safari") Image(systemName: "safari")
@ -319,7 +310,7 @@ struct BookmarkDetailView: View {
metaRow(icon: "safari") { metaRow(icon: "safari") {
Button(action: { Button(action: {
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url) URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) { }) {
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open") Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
.font(.subheadline) .font(.subheadline)
@ -464,7 +455,6 @@ struct ScrollOffsetPreferenceKey: PreferenceKey {
NavigationView { NavigationView {
BookmarkDetailView(bookmarkId: "123", BookmarkDetailView(bookmarkId: "123",
viewModel: .init(MockUseCaseFactory()), viewModel: .init(MockUseCaseFactory()),
webViewHeight: 300,
showingFontSettings: false, showingFontSettings: false,
showingLabelsSheet: false, showingLabelsSheet: false,
playerUIState: .init()) playerUIState: .init())

View File

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

View File

@ -1,7 +1,249 @@
import SwiftUI import SwiftUI
import WebKit import WebKit
struct WebView: UIViewRepresentable { // iOS 26+ Native SwiftUI WebView Implementation
@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
// More frequent attempts with shorter delays
let delays = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0] // 9 attempts
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
let result = try await webPage.callJavaScript("getContentHeight()")
if let height = result as? Double, height > 0 {
let cgHeight = CGFloat(height)
// Update height if it's significantly different or this is the first valid measurement
if lastHeight == 0 || abs(cgHeight - lastHeight) > 10 {
print("JavaScript height updated: \(height)px on attempt \(attempt)")
DispatchQueue.main.async {
self.onHeightChange(cgHeight)
}
lastHeight = cgHeight
}
// If height seems stable (no change in last few attempts), we can exit early
if attempt >= 3 && lastHeight > 0 {
print("Height stabilized at \(lastHeight)px")
return
}
}
} catch {
print("JavaScript attempt \(attempt) failed: \(error)")
}
}
// If no valid height was found, use fallback
if lastHeight == 0 {
print("No valid JavaScript height found, using fallback")
updateContentHeightFallback()
}
}
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))
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">
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
<style>
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: hidden; /* Disable scrolling in WebView */
height: auto;
}
html {
overflow: hidden; /* Disable scrolling */
height: auto;
}
h1, h2, h3, h4, h5, h6 {
color: \(isDarkMode ? "#ffffff" : "#000000");
margin-top: 24px;
margin-bottom: 12px;
font-weight: 600;
}
img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; }
a { color: \(isDarkMode ? "#0A84FF" : "#007AFF"); text-decoration: none; }
blockquote { border-left: 4px solid \(isDarkMode ? "#0A84FF" : "#007AFF"); margin: 16px 0; padding: 12px 16px; color: \(isDarkMode ? "#8E8E93" : "#666666"); 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; }
</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('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"
}
}
}
// Main WebView - automatically chooses best implementation
struct WebView: 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 WKWebView wrapper for older iOS
LegacyWebView(
htmlContent: htmlContent,
settings: settings,
onHeightChange: onHeightChange,
onScroll: onScroll
)
}
}
}
// Fallback: Original WKWebView Implementation
struct LegacyWebView: UIViewRepresentable {
let htmlContent: String let htmlContent: String
let settings: Settings let settings: Settings
let onHeightChange: (CGFloat) -> Void let onHeightChange: (CGFloat) -> Void
@ -26,7 +268,7 @@ struct WebView: UIViewRepresentable {
webView.allowsBackForwardNavigationGestures = false webView.allowsBackForwardNavigationGestures = false
webView.allowsLinkPreview = true webView.allowsLinkPreview = true
// Message Handler hier einmalig hinzufügen // Message Handler für Height und Scroll Updates
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate") webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress") webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
context.coordinator.onHeightChange = onHeightChange context.coordinator.onHeightChange = onHeightChange
@ -36,7 +278,7 @@ struct WebView: UIViewRepresentable {
} }
func updateUIView(_ webView: WKWebView, context: Context) { func updateUIView(_ webView: WKWebView, context: Context) {
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration // Update callbacks
context.coordinator.onHeightChange = onHeightChange context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll context.coordinator.onScroll = onScroll
@ -240,6 +482,7 @@ struct WebView: UIViewRepresentable {
document.querySelectorAll('img').forEach(img => { document.querySelectorAll('img').forEach(img => {
img.addEventListener('load', updateHeight); img.addEventListener('load', updateHeight);
}); });
// Scroll progress reporting // Scroll progress reporting
window.addEventListener('scroll', function() { window.addEventListener('scroll', function() {
var scrollTop = window.scrollY || document.documentElement.scrollTop; var scrollTop = window.scrollY || document.documentElement.scrollTop;
@ -285,6 +528,13 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
var onHeightChange: ((CGFloat) -> Void)? var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)? var onScroll: ((Double) -> Void)?
var hasHeightUpdate: Bool = false var hasHeightUpdate: Bool = false
var isScrolling: Bool = false
var scrollEndTimer: Timer?
deinit {
scrollEndTimer?.invalidate()
scrollEndTimer = nil
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated { if navigationAction.navigationType == .linkActivated {
@ -298,18 +548,34 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
} }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "heightUpdate", let height = message.body as? CGFloat { switch message.name {
case "heightUpdate":
guard let height = message.body as? CGFloat else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
if self.hasHeightUpdate == false { // Block height updates during active scrolling to prevent flicker
if !self.isScrolling && !self.hasHeightUpdate {
self.onHeightChange?(height) self.onHeightChange?(height)
self.hasHeightUpdate = true self.hasHeightUpdate = true
} }
} }
}
if message.name == "scrollProgress", let progress = message.body as? Double { case "scrollProgress":
guard let progress = message.body as? Double else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
// Track scrolling state
self.isScrolling = true
// Reset scrolling state after scroll ends
self.scrollEndTimer?.invalidate()
self.scrollEndTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] _ in
self?.isScrolling = false
}
self.onScroll?(progress) self.onScroll?(progress)
} }
default:
print("Unknown message: \(message.name)")
} }
} }
} }

View File

@ -150,6 +150,7 @@ class MockSaveSettingsUseCase: PSaveSettingsUseCase {
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {} func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {}
func execute(enableTTS: Bool) async throws {} func execute(enableTTS: Bool) async throws {}
func execute(theme: Theme) async throws {} func execute(theme: Theme) async throws {}
func execute(urlOpener: UrlOpener) async throws {}
} }
class MockGetBookmarkUseCase: PGetBookmarkUseCase { class MockGetBookmarkUseCase: PGetBookmarkUseCase {

View File

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

View File

@ -17,11 +17,11 @@ struct LegalNoticeView: View {
title: "App Publisher", title: "App Publisher",
content: """ content: """
Ilyas Hallak Ilyas Hallak
[Street Address] Albert-Bischof-Str. 18
[City, Postal Code] 28357 Bremen
[Country] Germany
Email: ilhallak@gmail.com Email: hi@ilyashallak.de
""" """
) )
@ -93,4 +93,4 @@ struct LegalNoticeView: View {
#Preview { #Preview {
LegalNoticeView() LegalNoticeView()
} }

View File

@ -12,7 +12,7 @@ struct PrivacyPolicyView: View {
.fontWeight(.bold) .fontWeight(.bold)
.padding(.bottom, 10) .padding(.bottom, 10)
Text("Last updated: [DATE]") Text("Last updated: September 20, 2025")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -44,7 +44,7 @@ struct PrivacyPolicyView: View {
sectionView( sectionView(
title: "Contact", title: "Contact",
content: "If you have questions about this privacy policy, please contact us at: ilhallak@gmail.com" content: "If you have questions about this privacy policy, please contact us at: hi@ilyashallak.de"
) )
} }
} }
@ -79,4 +79,4 @@ struct PrivacyPolicyView: View {
#Preview { #Preview {
PrivacyPolicyView() PrivacyPolicyView()
} }

View File

@ -8,7 +8,7 @@
import SwiftUI import SwiftUI
struct SettingsGeneralView: View { struct SettingsGeneralView: View {
@State private var viewModel: SettingsGeneralViewModel @State private var viewModel: SettingsGeneralViewModel
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) { init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
self.viewModel = viewModel self.viewModel = viewModel
@ -33,6 +33,23 @@ struct SettingsGeneralView: View {
.font(.footnote) .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 #if DEBUG
// Sync Settings // Sync Settings
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@ -55,8 +72,6 @@ struct SettingsGeneralView: View {
.font(.headline) .font(.headline)
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
.toggleStyle(SwitchToggleStyle()) .toggleStyle(SwitchToggleStyle())
Toggle("Open external links in in-app Safari", isOn: $viewModel.openExternalLinksInApp)
.toggleStyle(SwitchToggleStyle())
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead) Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
.toggleStyle(SwitchToggleStyle()) .toggleStyle(SwitchToggleStyle())
} }

View File

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

View File

@ -1,8 +1,25 @@
import UIKit import UIKit
import SafariServices import SafariServices
class SafariUtil { struct URLUtil {
static func openInSafari(url: String) {
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 } guard let url = URL(string: url) else { return }
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
@ -22,9 +39,7 @@ class SafariUtil {
presentingViewController.present(safariViewController, animated: true) presentingViewController.present(safariViewController, animated: true)
} }
} }
}
struct URLUtil {
static func extractDomain(from urlString: String) -> String? { static func extractDomain(from urlString: String) -> String? {
guard let url = URL(string: urlString), let host = url.host else { return nil } guard let url = URL(string: urlString), let host = url.host else { return nil }
return host.replacingOccurrences(of: "www.", with: "") return host.replacingOccurrences(of: "www.", with: "")

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?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"> <entity name="ArticleURLEntity" representedClassName="ArticleURLEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="tags" optional="YES" attributeType="String"/> <attribute name="tags" optional="YES" attributeType="String"/>
@ -57,6 +57,7 @@
<attribute name="fontSize" optional="YES" attributeType="String"/> <attribute name="fontSize" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="String"/> <attribute name="theme" optional="YES" attributeType="String"/>
<attribute name="token" optional="YES" attributeType="String"/> <attribute name="token" optional="YES" attributeType="String"/>
<attribute name="urlOpener" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class"> <entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/> <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