Compare commits
No commits in common. "d71bb1f6e157f159cee20094db1a3b05d2c1ea62" and "d6ea56cfa9fbe171614f50758650575f68e3883d" have entirely different histories.
d71bb1f6e1
...
d6ea56cfa9
3
.gitignore
vendored
@ -66,6 +66,3 @@ fastlane/AuthKey_JZJCQWW9N3.p8
|
||||
|
||||
# Documentation
|
||||
documentation/
|
||||
|
||||
# macOS
|
||||
**/.DS_Store
|
||||
10
README.md
@ -18,15 +18,9 @@ https://codeberg.org/readeck/readeck
|
||||
<img src="screenshots/ipad.webp" height="400" alt="iPad View">
|
||||
</p>
|
||||
|
||||
## Download
|
||||
## TestFlight Beta Access
|
||||
|
||||
### 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):
|
||||
You can now join the public TestFlight beta for the Readeck iOS app:
|
||||
|
||||
[Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR)
|
||||
|
||||
|
||||
@ -83,7 +83,6 @@
|
||||
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,
|
||||
@ -437,7 +436,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -470,7 +469,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -625,7 +624,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -669,7 +668,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@ -50,16 +50,6 @@ 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 {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
||||
class LabelsRepository: PLabelsRepository {
|
||||
private let api: PAPI
|
||||
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
@ -17,28 +17,27 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
|
||||
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)
|
||||
}
|
||||
for dto in dtos {
|
||||
if !tagExists(name: dto.name) {
|
||||
dto.toEntity(context: coreDataManager.context)
|
||||
}
|
||||
try backgroundContext.save()
|
||||
}
|
||||
try coreDataManager.context.save()
|
||||
}
|
||||
|
||||
private func tagExists(name: String, in context: NSManagedObjectContext) -> Bool {
|
||||
private func tagExists(name: String) -> Bool {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
|
||||
|
||||
do {
|
||||
let count = try context.count(for: fetchRequest)
|
||||
return count > 0
|
||||
} catch {
|
||||
return false
|
||||
var exists = false
|
||||
coreDataManager.context.performAndWait {
|
||||
do {
|
||||
let results = try coreDataManager.context.fetch(fetchRequest)
|
||||
exists = !results.isEmpty
|
||||
} catch {
|
||||
exists = false
|
||||
}
|
||||
}
|
||||
return exists
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,8 +14,6 @@ struct Settings {
|
||||
var theme: Theme? = nil
|
||||
var cardLayoutStyle: CardLayoutStyle? = nil
|
||||
|
||||
var urlOpener: UrlOpener? = nil
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
token != nil && !token!.isEmpty
|
||||
}
|
||||
@ -93,10 +91,6 @@ 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
|
||||
}
|
||||
@ -138,8 +132,7 @@ 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),
|
||||
urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue)
|
||||
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue)
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
} catch {
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@ 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 {
|
||||
@ -34,10 +33,4 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
.init(theme: theme)
|
||||
)
|
||||
}
|
||||
|
||||
func execute(urlOpener: UrlOpener) async throws {
|
||||
try await settingsRepository.saveSettings(
|
||||
.init(urlOpener: urlOpener)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,9 +46,6 @@
|
||||
"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:";
|
||||
|
||||
@ -25,9 +25,10 @@ struct BookmarkDetailView: View {
|
||||
|
||||
private let headerHeight: CGFloat = 360
|
||||
|
||||
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
|
||||
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
|
||||
}
|
||||
@ -60,8 +61,6 @@ struct BookmarkDetailView: View {
|
||||
if webViewHeight != height {
|
||||
webViewHeight = height
|
||||
}
|
||||
}, onScroll: { progress in
|
||||
// Handle scroll progress if needed
|
||||
})
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
@ -73,7 +72,7 @@ struct BookmarkDetailView: View {
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
@ -248,13 +247,23 @@ struct BookmarkDetailView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if viewModel.isLoadingArticle {
|
||||
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: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
@ -310,7 +319,7 @@ struct BookmarkDetailView: View {
|
||||
|
||||
metaRow(icon: "safari") {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||
.font(.subheadline)
|
||||
@ -455,6 +464,7 @@ struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
NavigationView {
|
||||
BookmarkDetailView(bookmarkId: "123",
|
||||
viewModel: .init(MockUseCaseFactory()),
|
||||
webViewHeight: 300,
|
||||
showingFontSettings: false,
|
||||
showingLabelsSheet: false,
|
||||
playerUIState: .init())
|
||||
|
||||
@ -14,7 +14,6 @@ extension View {
|
||||
|
||||
struct BookmarkCardView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
let bookmark: Bookmark
|
||||
let currentState: BookmarkState
|
||||
@ -256,7 +255,7 @@ struct BookmarkCardView: View {
|
||||
HStack {
|
||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||
.onTapGesture {
|
||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||
SafariUtil.openInSafari(url: bookmark.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -337,7 +336,7 @@ struct BookmarkCardView: View {
|
||||
HStack {
|
||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||
.onTapGesture {
|
||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||
SafariUtil.openInSafari(url: bookmark.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -435,4 +434,4 @@ struct IconBadge: View {
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,249 +1,7 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
// 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 {
|
||||
struct WebView: UIViewRepresentable {
|
||||
let htmlContent: String
|
||||
let settings: Settings
|
||||
let onHeightChange: (CGFloat) -> Void
|
||||
@ -268,7 +26,7 @@ struct LegacyWebView: UIViewRepresentable {
|
||||
webView.allowsBackForwardNavigationGestures = false
|
||||
webView.allowsLinkPreview = true
|
||||
|
||||
// Message Handler für Height und Scroll Updates
|
||||
// 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
|
||||
@ -278,7 +36,7 @@ struct LegacyWebView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
// Update callbacks
|
||||
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
|
||||
@ -482,7 +240,6 @@ struct LegacyWebView: UIViewRepresentable {
|
||||
document.querySelectorAll('img').forEach(img => {
|
||||
img.addEventListener('load', updateHeight);
|
||||
});
|
||||
|
||||
// Scroll progress reporting
|
||||
window.addEventListener('scroll', function() {
|
||||
var scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
@ -528,13 +285,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
var onHeightChange: ((CGFloat) -> Void)?
|
||||
var onScroll: ((Double) -> Void)?
|
||||
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) {
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
@ -548,34 +298,18 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
switch message.name {
|
||||
case "heightUpdate":
|
||||
guard let height = message.body as? CGFloat else { return }
|
||||
if message.name == "heightUpdate", let height = message.body as? CGFloat {
|
||||
DispatchQueue.main.async {
|
||||
// Block height updates during active scrolling to prevent flicker
|
||||
if !self.isScrolling && !self.hasHeightUpdate {
|
||||
if self.hasHeightUpdate == false {
|
||||
self.onHeightChange?(height)
|
||||
self.hasHeightUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
case "scrollProgress":
|
||||
guard let progress = message.body as? Double else { return }
|
||||
}
|
||||
if message.name == "scrollProgress", let progress = message.body as? Double {
|
||||
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)
|
||||
}
|
||||
|
||||
default:
|
||||
print("Unknown message: \(message.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,7 +150,6 @@ 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 {
|
||||
|
||||
@ -26,10 +26,6 @@ class AppSettings: ObservableObject {
|
||||
var theme: Theme {
|
||||
settings?.theme ?? .system
|
||||
}
|
||||
|
||||
var urlOpener: UrlOpener {
|
||||
settings?.urlOpener ?? .inAppBrowser
|
||||
}
|
||||
|
||||
init(settings: Settings? = nil) {
|
||||
self.settings = settings
|
||||
|
||||
@ -17,11 +17,11 @@ struct LegalNoticeView: View {
|
||||
title: "App Publisher",
|
||||
content: """
|
||||
Ilyas Hallak
|
||||
Albert-Bischof-Str. 18
|
||||
28357 Bremen
|
||||
Germany
|
||||
[Street Address]
|
||||
[City, Postal Code]
|
||||
[Country]
|
||||
|
||||
Email: hi@ilyashallak.de
|
||||
Email: ilhallak@gmail.com
|
||||
"""
|
||||
)
|
||||
|
||||
@ -93,4 +93,4 @@ struct LegalNoticeView: View {
|
||||
|
||||
#Preview {
|
||||
LegalNoticeView()
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ struct PrivacyPolicyView: View {
|
||||
.fontWeight(.bold)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Text("Last updated: September 20, 2025")
|
||||
Text("Last updated: [DATE]")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@ -44,7 +44,7 @@ struct PrivacyPolicyView: View {
|
||||
|
||||
sectionView(
|
||||
title: "Contact",
|
||||
content: "If you have questions about this privacy policy, please contact us at: hi@ilyashallak.de"
|
||||
content: "If you have questions about this privacy policy, please contact us at: ilhallak@gmail.com"
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -79,4 +79,4 @@ struct PrivacyPolicyView: View {
|
||||
|
||||
#Preview {
|
||||
PrivacyPolicyView()
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsGeneralView: View {
|
||||
@State private var viewModel: SettingsGeneralViewModel
|
||||
@State private var viewModel: SettingsGeneralViewModel
|
||||
|
||||
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
|
||||
self.viewModel = viewModel
|
||||
@ -33,23 +33,6 @@ 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) {
|
||||
@ -72,6 +55,8 @@ 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())
|
||||
}
|
||||
|
||||
@ -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,7 +36,6 @@ class SettingsGeneralViewModel {
|
||||
if let settings = try await loadSettingsUseCase.execute() {
|
||||
enableTTS = settings.enableTTS ?? false
|
||||
selectedTheme = settings.theme ?? .system
|
||||
urlOpener = settings.urlOpener ?? .inAppBrowser
|
||||
autoSyncEnabled = false
|
||||
}
|
||||
} catch {
|
||||
@ -49,7 +48,6 @@ class SettingsGeneralViewModel {
|
||||
do {
|
||||
try await saveSettingsUseCase.execute(enableTTS: enableTTS)
|
||||
try await saveSettingsUseCase.execute(theme: selectedTheme)
|
||||
try await saveSettingsUseCase.execute(urlOpener: urlOpener)
|
||||
|
||||
successMessage = "Settings saved"
|
||||
|
||||
|
||||
@ -1,25 +1,8 @@
|
||||
import UIKit
|
||||
import SafariServices
|
||||
|
||||
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) {
|
||||
class SafariUtil {
|
||||
static func openInSafari(url: String) {
|
||||
guard let url = URL(string: url) else { return }
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
@ -39,7 +22,9 @@ struct URLUtil {
|
||||
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: "")
|
||||
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="25A354" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24G84" 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,7 +57,6 @@
|
||||
<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"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |