Compare commits
12 Commits
d6ea56cfa9
...
d71bb1f6e1
| Author | SHA1 | Date | |
|---|---|---|---|
| d71bb1f6e1 | |||
| 3abeb3f3e4 | |||
| f3147a6cc6 | |||
| ac7f4e66eb | |||
|
|
413d3843aa | ||
|
|
b929611430 | ||
|
|
d369791f27 | ||
| 2791b7f227 | |||
| 52bf16a8eb | |||
| 051b5b169d | |||
|
|
f78de1f740 | ||
|
|
26990c59fa |
3
.gitignore
vendored
@ -66,3 +66,6 @@ fastlane/AuthKey_JZJCQWW9N3.p8
|
||||
|
||||
# Documentation
|
||||
documentation/
|
||||
|
||||
# macOS
|
||||
**/.DS_Store
|
||||
10
README.md
@ -18,9 +18,15 @@ https://codeberg.org/readeck/readeck
|
||||
<img src="screenshots/ipad.webp" height="400" alt="iPad View">
|
||||
</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)
|
||||
|
||||
|
||||
@ -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 = 25;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -469,7 +470,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -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 = 25;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -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 = 25;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
11
readeck/Domain/Model/UrlOpener.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,9 @@
|
||||
"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,10 +25,9 @@ struct BookmarkDetailView: View {
|
||||
|
||||
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.viewModel = viewModel
|
||||
self.webViewHeight = webViewHeight
|
||||
self.showingFontSettings = showingFontSettings
|
||||
self.showingLabelsSheet = showingLabelsSheet
|
||||
}
|
||||
@ -61,6 +60,8 @@ struct BookmarkDetailView: View {
|
||||
if webViewHeight != height {
|
||||
webViewHeight = height
|
||||
}
|
||||
}, onScroll: { progress in
|
||||
// Handle scroll progress if needed
|
||||
})
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
@ -72,7 +73,7 @@ struct BookmarkDetailView: View {
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
@ -247,23 +248,13 @@ struct BookmarkDetailView: View {
|
||||
|
||||
@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 {
|
||||
if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
@ -319,7 +310,7 @@ struct BookmarkDetailView: View {
|
||||
|
||||
metaRow(icon: "safari") {
|
||||
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")
|
||||
.font(.subheadline)
|
||||
@ -464,7 +455,6 @@ struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
NavigationView {
|
||||
BookmarkDetailView(bookmarkId: "123",
|
||||
viewModel: .init(MockUseCaseFactory()),
|
||||
webViewHeight: 300,
|
||||
showingFontSettings: false,
|
||||
showingLabelsSheet: false,
|
||||
playerUIState: .init())
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,249 @@
|
||||
import SwiftUI
|
||||
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 settings: Settings
|
||||
let onHeightChange: (CGFloat) -> Void
|
||||
@ -26,7 +268,7 @@ struct WebView: UIViewRepresentable {
|
||||
webView.allowsBackForwardNavigationGestures = false
|
||||
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: "scrollProgress")
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
@ -36,7 +278,7 @@ struct WebView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
||||
// Update callbacks
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
|
||||
@ -240,6 +482,7 @@ struct WebView: UIViewRepresentable {
|
||||
document.querySelectorAll('img').forEach(img => {
|
||||
img.addEventListener('load', updateHeight);
|
||||
});
|
||||
|
||||
// Scroll progress reporting
|
||||
window.addEventListener('scroll', function() {
|
||||
var scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
@ -285,6 +528,13 @@ 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 {
|
||||
@ -298,18 +548,34 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
}
|
||||
|
||||
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 {
|
||||
if self.hasHeightUpdate == false {
|
||||
// Block height updates during active scrolling to prevent flicker
|
||||
if !self.isScrolling && !self.hasHeightUpdate {
|
||||
self.onHeightChange?(height)
|
||||
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 {
|
||||
// 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,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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -17,11 +17,11 @@ struct LegalNoticeView: View {
|
||||
title: "App Publisher",
|
||||
content: """
|
||||
Ilyas Hallak
|
||||
[Street Address]
|
||||
[City, Postal Code]
|
||||
[Country]
|
||||
Albert-Bischof-Str. 18
|
||||
28357 Bremen
|
||||
Germany
|
||||
|
||||
Email: ilhallak@gmail.com
|
||||
Email: hi@ilyashallak.de
|
||||
"""
|
||||
)
|
||||
|
||||
@ -93,4 +93,4 @@ struct LegalNoticeView: View {
|
||||
|
||||
#Preview {
|
||||
LegalNoticeView()
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ struct PrivacyPolicyView: View {
|
||||
.fontWeight(.bold)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Text("Last updated: [DATE]")
|
||||
Text("Last updated: September 20, 2025")
|
||||
.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: 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 {
|
||||
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,6 +33,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 +72,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())
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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: "")
|
||||
@ -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"/>
|
||||
|
||||
BIN
screenshots/appstore_ipad.pxd
Normal file
BIN
screenshots/appstore_iphone.pxd
Normal file
BIN
screenshots/ipad_1.jpg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
screenshots/ipad_2.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
screenshots/ipad_3.jpg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
screenshots/ipad_4.jpg
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
screenshots/ipad_5.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
screenshots/iphone_1.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
screenshots/iphone_2.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
screenshots/iphone_3.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
screenshots/iphone_4.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
screenshots/iphone_5.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |