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
|
||||||
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">
|
<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)
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
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(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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:";
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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: "")
|
||||||
@ -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"/>
|
||||||
|
|||||||
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 |