Compare commits
No commits in common. "fef187629771c4dcb1c9b13a7f748e39c02ba68d" and "534ceddad46e98bcacc6b01f36807809a1f45872" have entirely different histories.
fef1876297
...
534ceddad4
3
.gitignore
vendored
@ -66,6 +66,3 @@ fastlane/AuthKey_JZJCQWW9N3.p8
|
|||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
documentation/
|
documentation/
|
||||||
|
|
||||||
# macOS
|
|
||||||
**/.DS_Store
|
|
||||||
44
README.md
@ -7,17 +7,24 @@ A native iOS client for [readeck](https://readeck.org) bookmark management.
|
|||||||
The official repository is on Codeberg:
|
The official repository is on Codeberg:
|
||||||
https://codeberg.org/readeck/readeck
|
https://codeberg.org/readeck/readeck
|
||||||
|
|
||||||
## Download
|
## Screenshots
|
||||||
|
|
||||||
### App Store (Stable Releases)
|
<p align="center">
|
||||||
<a href="https://apps.apple.com/de/app/readeck/id6748764703">
|
<img src="screenshots/main.webp" height="400" alt="Main View">
|
||||||
<img src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" alt="Download on the App Store" width="200">
|
<img src="screenshots/detail.webp" height="400" alt="Detail View">
|
||||||
</a>
|
<img src="screenshots/new.webp" height="400" alt="Add Bookmark">
|
||||||
|
<img src="screenshots/more.webp" height="400" alt="More Options">
|
||||||
|
<img src="screenshots/share.webp" height="400" alt="Share Extension">
|
||||||
|
<img src="screenshots/ipad.webp" height="400" alt="iPad View">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## TestFlight Beta Access
|
||||||
|
|
||||||
|
You can now join the public TestFlight beta for the Readeck iOS app:
|
||||||
|
|
||||||
### TestFlight Beta Access (Early Releases)
|
|
||||||
[Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR)
|
[Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR)
|
||||||
|
|
||||||
For early access to new features and beta versions (use with caution). To participate, simply install TestFlight from the App Store and open the link above on your iPhone, iPad, or Mac. This early version lets you explore all core features before the official release. Your feedback is incredibly valuable and will help shape the final app.
|
To participate, simply install TestFlight from the App Store and open the link above on your iPhone, iPad, or Mac. This early version lets you explore all core features before the official release. Your feedback is incredibly valuable and will help shape the final app.
|
||||||
|
|
||||||
What to test:
|
What to test:
|
||||||
- See the feature list below for an overview of what you can try out.
|
- See the feature list below for an overview of what you can try out.
|
||||||
@ -27,29 +34,6 @@ Please report any bugs, crashes, or suggestions directly through TestFlight, or
|
|||||||
|
|
||||||
If you are interested in joining the internal beta, please contact me directly at mooonki:matrix.org.
|
If you are interested in joining the internal beta, please contact me directly at mooonki:matrix.org.
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
### iPhone
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="screenshots/iphone_1.png" height="400" alt="iPhone Screenshot 1">
|
|
||||||
<img src="screenshots/iphone_2.png" height="400" alt="iPhone Screenshot 2">
|
|
||||||
<img src="screenshots/iphone_3.png" height="400" alt="iPhone Screenshot 3">
|
|
||||||
<img src="screenshots/iphone_4.png" height="400" alt="iPhone Screenshot 4">
|
|
||||||
<img src="screenshots/iphone_5.png" height="400" alt="iPhone Screenshot 5">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### iPad
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="screenshots/ipad_1.jpg" height="400" alt="iPad Screenshot 1">
|
|
||||||
<img src="screenshots/ipad_2.jpg" height="400" alt="iPad Screenshot 2">
|
|
||||||
<img src="screenshots/ipad_3.jpg" height="400" alt="iPad Screenshot 3">
|
|
||||||
<img src="screenshots/ipad_4.jpg" height="400" alt="iPad Screenshot 4">
|
|
||||||
<img src="screenshots/ipad_5.jpg" height="400" alt="iPad Screenshot 5">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
|
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
|
||||||
|
|||||||
@ -208,15 +208,19 @@ struct ShareBookmarkView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addCustomTag() {
|
private func addCustomTag() {
|
||||||
let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText)
|
let trimmed = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let availableLabels = viewModel.labels.map { $0.name }
|
guard !trimmed.isEmpty else { return }
|
||||||
let currentLabels = Array(viewModel.selectedLabels)
|
|
||||||
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
|
|
||||||
|
|
||||||
for label in uniqueLabels {
|
let lowercased = trimmed.lowercased()
|
||||||
viewModel.selectedLabels.insert(label)
|
let allExisting = Set(viewModel.labels.map { $0.name.lowercased() })
|
||||||
}
|
let allSelected = Set(viewModel.selectedLabels.map { $0.lowercased() })
|
||||||
|
|
||||||
|
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
||||||
|
// Tag already exists, don't add
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
viewModel.selectedLabels.insert(trimmed)
|
||||||
viewModel.searchText = ""
|
viewModel.searchText = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -83,7 +83,6 @@
|
|||||||
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,
|
||||||
@ -437,7 +436,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 = 29;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -450,7 +449,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -470,7 +469,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 = 29;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -483,7 +482,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -625,7 +624,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 = 29;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
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;
|
||||||
@ -648,7 +647,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -669,7 +668,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 = 29;
|
CURRENT_PROJECT_VERSION = 24;
|
||||||
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;
|
||||||
@ -692,7 +691,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@ -241,9 +241,7 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let tag {
|
if let tag {
|
||||||
// URL-encode label with quotes for proper API handling
|
queryItems.append(URLQueryItem(name: "labels", value: tag))
|
||||||
let encodedTag = "\"\(tag)\""
|
|
||||||
queryItems.append(URLQueryItem(name: "labels", value: encodedTag))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !queryItems.isEmpty {
|
if !queryItems.isEmpty {
|
||||||
|
|||||||
@ -50,16 +50,6 @@ 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,13 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
/// Returns a localized version of the string using NSLocalizedString
|
|
||||||
var localized: String {
|
|
||||||
return NSLocalizedString(self, comment: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a localized version of the string with comment
|
|
||||||
func localized(comment: String) -> String {
|
|
||||||
return NSLocalizedString(self, comment: comment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
|
|
||||||
class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
class LabelsRepository: PLabelsRepository {
|
||||||
private let api: PAPI
|
private let api: PAPI
|
||||||
|
|
||||||
private let coreDataManager = CoreDataManager.shared
|
private let coreDataManager = CoreDataManager.shared
|
||||||
@ -17,28 +17,27 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
|
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 {
|
for dto in dtos {
|
||||||
if !self.tagExists(name: dto.name, in: backgroundContext) {
|
if !tagExists(name: dto.name) {
|
||||||
dto.toEntity(context: backgroundContext)
|
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()
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
|
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
|
||||||
|
|
||||||
|
var exists = false
|
||||||
|
coreDataManager.context.performAndWait {
|
||||||
do {
|
do {
|
||||||
let count = try context.count(for: fetchRequest)
|
let results = try coreDataManager.context.fetch(fetchRequest)
|
||||||
return count > 0
|
exists = !results.isEmpty
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
exists = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return exists
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,6 @@ 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
|
||||||
}
|
}
|
||||||
@ -93,10 +91,6 @@ 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
|
||||||
}
|
}
|
||||||
@ -138,8 +132,7 @@ 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 {
|
||||||
|
|||||||
@ -10,38 +10,19 @@ protocol TokenProvider {
|
|||||||
class KeychainTokenProvider: TokenProvider {
|
class KeychainTokenProvider: TokenProvider {
|
||||||
private let keychainHelper = KeychainHelper.shared
|
private let keychainHelper = KeychainHelper.shared
|
||||||
|
|
||||||
// Cache to avoid repeated keychain access
|
|
||||||
private var cachedToken: String?
|
|
||||||
private var cachedEndpoint: String?
|
|
||||||
|
|
||||||
func getToken() async -> String? {
|
func getToken() async -> String? {
|
||||||
if let cached = cachedToken {
|
return keychainHelper.loadToken()
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = keychainHelper.loadToken()
|
|
||||||
cachedToken = token
|
|
||||||
return token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEndpoint() async -> String? {
|
func getEndpoint() async -> String? {
|
||||||
if let cached = cachedEndpoint {
|
return keychainHelper.loadEndpoint()
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint = keychainHelper.loadEndpoint()
|
|
||||||
cachedEndpoint = endpoint
|
|
||||||
return endpoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setToken(_ token: String) async {
|
func setToken(_ token: String) async {
|
||||||
keychainHelper.saveToken(token)
|
keychainHelper.saveToken(token)
|
||||||
cachedToken = token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearToken() async {
|
func clearToken() async {
|
||||||
keychainHelper.clearCredentials()
|
keychainHelper.clearCredentials()
|
||||||
cachedToken = nil
|
|
||||||
cachedEndpoint = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct LabelUtils {
|
|
||||||
/// Processes a label input string and returns it as a single trimmed label
|
|
||||||
/// - Parameter input: The input string containing a label (spaces are allowed)
|
|
||||||
/// - Returns: Array containing the trimmed label, or empty array if input is empty
|
|
||||||
static func splitLabelsFromInput(_ input: String) -> [String] {
|
|
||||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return trimmed.isEmpty ? [] : [trimmed]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filters out labels that already exist in current or available labels
|
|
||||||
/// - Parameters:
|
|
||||||
/// - labels: Array of labels to filter
|
|
||||||
/// - currentLabels: Currently selected labels
|
|
||||||
/// - availableLabels: Available labels (optional)
|
|
||||||
/// - Returns: Array of unique labels that don't already exist
|
|
||||||
static func filterUniqueLabels(_ labels: [String], currentLabels: [String], availableLabels: [String] = []) -> [String] {
|
|
||||||
let currentSet = Set(currentLabels.map { $0.lowercased() })
|
|
||||||
let availableSet = Set(availableLabels.map { $0.lowercased() })
|
|
||||||
|
|
||||||
return labels.filter { label in
|
|
||||||
let lowercased = label.lowercased()
|
|
||||||
return !currentSet.contains(lowercased) && !availableSet.contains(lowercased)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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(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 {
|
||||||
@ -34,10 +33,4 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
|
|||||||
.init(theme: theme)
|
.init(theme: theme)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func execute(urlOpener: UrlOpener) async throws {
|
|
||||||
try await settingsRepository.saveSettings(
|
|
||||||
.init(urlOpener: urlOpener)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,34 +18,6 @@
|
|||||||
"%lld/%lld" = "%1$lld/%2$lld";
|
"%lld/%lld" = "%1$lld/%2$lld";
|
||||||
"12 min • Today • example.com" = "12 min • Today • example.com";
|
"12 min • Today • example.com" = "12 min • Today • example.com";
|
||||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
||||||
|
|
||||||
/* Legal & Privacy */
|
|
||||||
"Legal & Privacy" = "Legal & Privacy";
|
|
||||||
"Privacy Policy" = "Privacy Policy";
|
|
||||||
"Legal Notice" = "Legal Notice";
|
|
||||||
"Report an Issue" = "Report an Issue";
|
|
||||||
"Contact Support" = "Contact Support";
|
|
||||||
|
|
||||||
/* Navigation & States */
|
|
||||||
"All" = "All";
|
|
||||||
"Unread" = "Unread";
|
|
||||||
"Favorites" = "Favorites";
|
|
||||||
"Archive" = "Archive";
|
|
||||||
"Search" = "Search";
|
|
||||||
"Settings" = "Settings";
|
|
||||||
"Articles" = "Articles";
|
|
||||||
"Videos" = "Videos";
|
|
||||||
"Pictures" = "Pictures";
|
|
||||||
"Tags" = "Tags";
|
|
||||||
|
|
||||||
/* Settings Sections */
|
|
||||||
"Font Settings" = "Font Settings";
|
|
||||||
"Appearance" = "Appearance";
|
|
||||||
"Cache Settings" = "Cache Settings";
|
|
||||||
"General Settings" = "General Settings";
|
|
||||||
"Server Settings" = "Server Settings";
|
|
||||||
"Server Connection" = "Server Connection";
|
|
||||||
|
|
||||||
"Add" = "Add";
|
"Add" = "Add";
|
||||||
"Add new tag:" = "Add new tag:";
|
"Add new tag:" = "Add new tag:";
|
||||||
"all" = "all";
|
"all" = "all";
|
||||||
|
|||||||
@ -5,156 +5,4 @@
|
|||||||
Created by conversion from Localizable.xcstrings
|
Created by conversion from Localizable.xcstrings
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
"all" = "Ale";
|
||||||
"" = "";
|
|
||||||
"(%lld found)" = "(%lld gefunden)";
|
|
||||||
"%" = "%";
|
|
||||||
"%@ (%lld)" = "%1$@ (%2$lld)";
|
|
||||||
"%lld" = "%lld";
|
|
||||||
"%lld articles in the queue" = "%lld Artikel in der Warteschlange";
|
|
||||||
"%lld bookmark%@ synced successfully" = "%1$lld Lesezeichen%2$@ erfolgreich synchronisiert";
|
|
||||||
"%lld bookmark%@ waiting for sync" = "%1$lld Lesezeichen%2$@ warten auf Synchronisation";
|
|
||||||
"%lld min" = "%lld Min";
|
|
||||||
"%lld." = "%lld.";
|
|
||||||
"%lld/%lld" = "%1$lld/%2$lld";
|
|
||||||
"12 min • Today • example.com" = "12 Min • Heute • example.com";
|
|
||||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Aktiviere die Vorlese-Funktion, um deine Artikel vorlesen zu lassen. Dies ist eine sehr frühe Vorschau und funktioniert möglicherweise noch nicht perfekt.";
|
|
||||||
|
|
||||||
/* Legal & Privacy */
|
|
||||||
"Legal & Privacy" = "Rechtliches & Datenschutz";
|
|
||||||
"Privacy Policy" = "Datenschutzerklärung";
|
|
||||||
"Legal Notice" = "Impressum";
|
|
||||||
"Report an Issue" = "Problem melden";
|
|
||||||
"Contact Support" = "Support kontaktieren";
|
|
||||||
|
|
||||||
/* Navigation & States */
|
|
||||||
"All" = "Alle";
|
|
||||||
"Unread" = "Ungelesen";
|
|
||||||
"Favorites" = "Favoriten";
|
|
||||||
"Archive" = "Archiv";
|
|
||||||
"Search" = "Suchen";
|
|
||||||
"Settings" = "Einstellungen";
|
|
||||||
"Articles" = "Artikel";
|
|
||||||
"Videos" = "Videos";
|
|
||||||
"Pictures" = "Bilder";
|
|
||||||
"Tags" = "Labels";
|
|
||||||
|
|
||||||
/* Settings Sections */
|
|
||||||
"Font Settings" = "Schriftart-Einstellungen";
|
|
||||||
"Appearance" = "Darstellung";
|
|
||||||
"Cache Settings" = "Cache-Einstellungen";
|
|
||||||
"General Settings" = "Allgemeine Einstellungen";
|
|
||||||
"Server Settings" = "Server-Einstellungen";
|
|
||||||
"Server Connection" = "Server-Verbindung";
|
|
||||||
"Open external links in" = "Öffne externe Links in";
|
|
||||||
"In App Browser" = "In App Browser";
|
|
||||||
"Default Browser" = "Standard Browser";
|
|
||||||
|
|
||||||
"Add" = "Hinzufügen";
|
|
||||||
"Add new tag:" = "Neues Label hinzufügen:";
|
|
||||||
"all" = "alle";
|
|
||||||
"All tags selected" = "Alle Labels ausgewählt";
|
|
||||||
"Archive" = "Archivieren";
|
|
||||||
"Archive bookmark" = "Lesezeichen archivieren";
|
|
||||||
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Dieses Lesezeichen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.";
|
|
||||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Wirklich abmelden? Dies löscht alle Anmeldedaten und führt zurück zur Einrichtung.";
|
|
||||||
"Available tags" = "Verfügbare Labels";
|
|
||||||
"Cancel" = "Abbrechen";
|
|
||||||
"Category-specific Levels" = "Kategorie-spezifische Level";
|
|
||||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Änderungen werden sofort wirksam. Niedrigere Log-Level enthalten höhere (Debug enthält alle, Critical nur kritische Nachrichten).";
|
|
||||||
"Close" = "Schließen";
|
|
||||||
"Configure log levels and categories" = "Log-Level und Kategorien konfigurieren";
|
|
||||||
"Critical" = "Kritisch";
|
|
||||||
"Debug" = "Debug";
|
|
||||||
"DEBUG BUILD" = "DEBUG BUILD";
|
|
||||||
"Debug Settings" = "Debug-Einstellungen";
|
|
||||||
"Delete" = "Löschen";
|
|
||||||
"Delete Bookmark" = "Lesezeichen löschen";
|
|
||||||
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
|
|
||||||
"Done" = "Fertig";
|
|
||||||
"Enter an optional title..." = "Optionalen Titel eingeben...";
|
|
||||||
"Enter your Readeck server details to get started." = "Readeck-Server-Details eingeben, um zu beginnen.";
|
|
||||||
"Error" = "Fehler";
|
|
||||||
"Error: %@" = "Fehler: %@";
|
|
||||||
"Favorite" = "Favorit";
|
|
||||||
"Finished reading?" = "Fertig gelesen?";
|
|
||||||
"Font" = "Schrift";
|
|
||||||
"Font family" = "Schriftart";
|
|
||||||
"Font Settings" = "Schrift-Einstellungen";
|
|
||||||
"Font size" = "Schriftgröße";
|
|
||||||
"From Bremen with 💚" = "Aus Bremen mit 💚";
|
|
||||||
"General" = "Allgemein";
|
|
||||||
"Global Level" = "Globales Level";
|
|
||||||
"Global Minimum Level" = "Globales Minimum-Level";
|
|
||||||
"Global Settings" = "Globale Einstellungen";
|
|
||||||
"https://example.com" = "https://example.com";
|
|
||||||
"https://readeck.example.com" = "https://readeck.example.com";
|
|
||||||
"Include Source Location" = "Quellort einschließen";
|
|
||||||
"Info" = "Info";
|
|
||||||
"Jump to last read position (%lld%%)" = "Zur letzten Leseposition springen (%lld%%)";
|
|
||||||
"Key" = "Schlüssel";
|
|
||||||
"Level for %@" = "Level für %@";
|
|
||||||
"Loading %@" = "Lade %@";
|
|
||||||
"Loading article..." = "Artikel wird geladen...";
|
|
||||||
"Logging Configuration" = "Logging-Konfiguration";
|
|
||||||
"Login & Save" = "Anmelden & Speichern";
|
|
||||||
"Logout" = "Abmelden";
|
|
||||||
"Logs below this level will be filtered out globally" = "Logs unter diesem Level werden global herausgefiltert";
|
|
||||||
"Manage Labels" = "Labels verwalten";
|
|
||||||
"Mark as favorite" = "Als Favorit markieren";
|
|
||||||
"More" = "Mehr";
|
|
||||||
"New Bookmark" = "Neues Lesezeichen";
|
|
||||||
"No articles in the queue" = "Keine Artikel in der Warteschlange";
|
|
||||||
"No bookmarks" = "Keine Lesezeichen";
|
|
||||||
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
|
|
||||||
"No bookmarks found." = "Keine Lesezeichen gefunden.";
|
|
||||||
"No results" = "Keine Ergebnisse";
|
|
||||||
"Notice" = "Hinweis";
|
|
||||||
"OK" = "OK";
|
|
||||||
"Optional: Custom title" = "Optional: Benutzerdefinierter Titel";
|
|
||||||
"Password" = "Passwort";
|
|
||||||
"Paste" = "Einfügen";
|
|
||||||
"Please wait while we fetch your bookmarks..." = "Bitte warten, während die Lesezeichen geladen werden...";
|
|
||||||
"Preview" = "Vorschau";
|
|
||||||
"Progress: %lld%%" = "Fortschritt: %lld%%";
|
|
||||||
"Re-login & Save" = "Erneut anmelden & Speichern";
|
|
||||||
"Read Aloud Feature" = "Vorlese-Funktion";
|
|
||||||
"Read article aloud" = "Artikel vorlesen";
|
|
||||||
"Read-aloud Queue" = "Vorlese-Warteschlange";
|
|
||||||
"readeck Bookmark Title" = "readeck Lesezeichen-Titel";
|
|
||||||
"Reading %lld/%lld: " = "Lese %1$lld/%2$lld: ";
|
|
||||||
"Remove" = "Entfernen";
|
|
||||||
"Reset" = "Zurücksetzen";
|
|
||||||
"Reset to Defaults" = "Auf Standardwerte zurücksetzen";
|
|
||||||
"Restore" = "Wiederherstellen";
|
|
||||||
"Resume listening" = "Zuhören fortsetzen";
|
|
||||||
"Save bookmark" = "Lesezeichen speichern";
|
|
||||||
"Save Bookmark" = "Lesezeichen speichern";
|
|
||||||
"Saving..." = "Speichern...";
|
|
||||||
"Search" = "Suchen";
|
|
||||||
"Search or add new tag..." = "Suchen oder neues Label hinzufügen...";
|
|
||||||
"Search results" = "Suchergebnisse";
|
|
||||||
"Search..." = "Suchen...";
|
|
||||||
"Searching..." = "Suche...";
|
|
||||||
"Select a bookmark or tag" = "Lesezeichen oder Label auswählen";
|
|
||||||
"Selected tags" = "Ausgewählte Labels";
|
|
||||||
"Server Endpoint" = "Server-Endpunkt";
|
|
||||||
"Server not reachable - saving locally" = "Server nicht erreichbar - speichere lokal";
|
|
||||||
"Settings" = "Einstellungen";
|
|
||||||
"Show Performance Logs" = "Performance-Logs anzeigen";
|
|
||||||
"Show Timestamps" = "Zeitstempel anzeigen";
|
|
||||||
"Speed" = "Geschwindigkeit";
|
|
||||||
"Syncing with server..." = "Synchronisiere mit Server...";
|
|
||||||
"Theme" = "Design";
|
|
||||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "So werden Lesezeichen-Beschreibungen und Artikeltexte in der App angezeigt. Franz jagt im komplett verwahrlosten Taxi quer durch Bayern.";
|
|
||||||
"Try Again" = "Erneut versuchen";
|
|
||||||
"Unable to load bookmarks" = "Lesezeichen können nicht geladen werden";
|
|
||||||
"Unarchive Bookmark" = "Lesezeichen aus Archiv entfernen";
|
|
||||||
"URL in clipboard:" = "URL in Zwischenablage:";
|
|
||||||
"Username" = "Benutzername";
|
|
||||||
"Version %@" = "Version %@";
|
|
||||||
"Warning" = "Warnung";
|
|
||||||
"Your current server connection and login credentials." = "Aktuelle Serververbindung und Anmeldedaten.";
|
|
||||||
"Your Password" = "Passwort";
|
|
||||||
"Your Username" = "Benutzername";
|
|
||||||
|
|
||||||
@ -18,34 +18,6 @@
|
|||||||
"%lld/%lld" = "%1$lld/%2$lld";
|
"%lld/%lld" = "%1$lld/%2$lld";
|
||||||
"12 min • Today • example.com" = "12 min • Today • example.com";
|
"12 min • Today • example.com" = "12 min • Today • example.com";
|
||||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
||||||
|
|
||||||
/* Legal & Privacy */
|
|
||||||
"Legal & Privacy" = "Legal & Privacy";
|
|
||||||
"Privacy Policy" = "Privacy Policy";
|
|
||||||
"Legal Notice" = "Legal Notice";
|
|
||||||
"Report an Issue" = "Report an Issue";
|
|
||||||
"Contact Support" = "Contact Support";
|
|
||||||
|
|
||||||
/* Navigation & States */
|
|
||||||
"All" = "All";
|
|
||||||
"Unread" = "Unread";
|
|
||||||
"Favorites" = "Favorites";
|
|
||||||
"Archive" = "Archive";
|
|
||||||
"Search" = "Search";
|
|
||||||
"Settings" = "Settings";
|
|
||||||
"Articles" = "Articles";
|
|
||||||
"Videos" = "Videos";
|
|
||||||
"Pictures" = "Pictures";
|
|
||||||
"Tags" = "Tags";
|
|
||||||
|
|
||||||
/* Settings Sections */
|
|
||||||
"Font Settings" = "Font Settings";
|
|
||||||
"Appearance" = "Appearance";
|
|
||||||
"Cache Settings" = "Cache Settings";
|
|
||||||
"General Settings" = "General Settings";
|
|
||||||
"Server Settings" = "Server Settings";
|
|
||||||
"Server Connection" = "Server Connection";
|
|
||||||
|
|
||||||
"Add" = "Add";
|
"Add" = "Add";
|
||||||
"Add new tag:" = "Add new tag:";
|
"Add new tag:" = "Add new tag:";
|
||||||
"all" = "all";
|
"all" = "all";
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
# Release Notes
|
|
||||||
|
|
||||||
Thanks for using the Readeck iOS app! Below are the release notes for each version.
|
|
||||||
|
|
||||||
**AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features.
|
|
||||||
|
|
||||||
## Version 1.1
|
|
||||||
|
|
||||||
There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development.
|
|
||||||
|
|
||||||
### Modern Reading Experience (iOS 26+)
|
|
||||||
|
|
||||||
- **Completely rebuilt article view** for the latest iOS version
|
|
||||||
- Smoother scrolling and faster page loading
|
|
||||||
- Better battery life and memory usage
|
|
||||||
- Native iOS integration for the best experience
|
|
||||||
|
|
||||||
### Quick Actions
|
|
||||||
|
|
||||||
- **Smart action buttons** appear automatically when you're almost done reading
|
|
||||||
- Beautiful, modern design that blends with your content
|
|
||||||
- Quickly favorite or archive articles without scrolling back up
|
|
||||||
- Buttons fade away elegantly when you scroll back
|
|
||||||
- Your progress bar now reflects the entire article length
|
|
||||||
|
|
||||||
### Beautiful Article Images
|
|
||||||
|
|
||||||
- **Article header images now display properly** without awkward cropping
|
|
||||||
- Full images with a subtle blurred background
|
|
||||||
- Tap to view images in full screen
|
|
||||||
|
|
||||||
### Smoother Performance
|
|
||||||
|
|
||||||
- **Dramatically improved scrolling** - no more stuttering or lag
|
|
||||||
- Faster article loading times
|
|
||||||
- Better handling of long articles with many images
|
|
||||||
- Overall snappier app experience
|
|
||||||
|
|
||||||
### Open Links Your Way
|
|
||||||
|
|
||||||
- **Choose your preferred browser** for opening links
|
|
||||||
- Open in Safari or in-app browser
|
|
||||||
- Thanks to christian-putzke for this contribution!
|
|
||||||
|
|
||||||
### Fixes & Improvements
|
|
||||||
|
|
||||||
- Articles no longer overflow the screen width
|
|
||||||
- Fixed spacing issues in article view
|
|
||||||
- Improved progress calculation accuracy
|
|
||||||
- Better handling of article content
|
|
||||||
- Fixed issues with label names containing spaces
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Version 1.0 (Initial Release)
|
|
||||||
|
|
||||||
### Core Features
|
|
||||||
|
|
||||||
- Browse and read saved articles
|
|
||||||
- Bookmark management with labels
|
|
||||||
- Full article view with custom fonts
|
|
||||||
- Text-to-speech support (Beta)
|
|
||||||
- Archive and favorite functionality
|
|
||||||
- Choose different Layouts (Compact, Magazine, Natural)
|
|
||||||
|
|
||||||
### Reading Experience
|
|
||||||
|
|
||||||
- Clean, distraction-free reading interface
|
|
||||||
- Customizable font settings
|
|
||||||
- Header Image viewer with zoom support
|
|
||||||
- Progress tracking per article
|
|
||||||
- Dark mode support
|
|
||||||
|
|
||||||
### Organization
|
|
||||||
|
|
||||||
- Label system for categorization (multi-select)
|
|
||||||
- Search
|
|
||||||
- Archive completed articles
|
|
||||||
- Jump to last read position
|
|
||||||
|
|
||||||
### Share Extension
|
|
||||||
|
|
||||||
- Save articles from other apps
|
|
||||||
- Quick access to save and label bookmarks
|
|
||||||
- Save Bookmarks offline if your server is not reachable and sync later
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,551 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import SafariServices
|
|
||||||
|
|
||||||
// PreferenceKey for scroll offset tracking
|
|
||||||
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue: CGPoint = .zero
|
|
||||||
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
|
|
||||||
value = nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreferenceKey for content height tracking
|
|
||||||
struct ContentHeightPreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue: CGFloat = 0
|
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
||||||
value = nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BookmarkDetailLegacyView: View {
|
|
||||||
let bookmarkId: String
|
|
||||||
@Binding var useNativeWebView: Bool
|
|
||||||
|
|
||||||
// MARK: - States
|
|
||||||
|
|
||||||
@State private var viewModel: BookmarkDetailViewModel
|
|
||||||
@State private var webViewHeight: CGFloat = 300
|
|
||||||
@State private var contentEndPosition: CGFloat = 0
|
|
||||||
@State private var initialContentEndPosition: CGFloat = 0
|
|
||||||
@State private var showingFontSettings = false
|
|
||||||
@State private var showingLabelsSheet = false
|
|
||||||
@State private var readingProgress: Double = 0.0
|
|
||||||
@State private var lastSentProgress: Double = 0.0
|
|
||||||
@State private var showJumpToProgressButton: Bool = false
|
|
||||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
|
||||||
@State private var showingImageViewer = false
|
|
||||||
|
|
||||||
// MARK: - Envs
|
|
||||||
|
|
||||||
@EnvironmentObject var playerUIState: PlayerUIState
|
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
private let headerHeight: CGFloat = 360
|
|
||||||
|
|
||||||
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
|
|
||||||
self.bookmarkId = bookmarkId
|
|
||||||
self._useNativeWebView = useNativeWebView
|
|
||||||
self.viewModel = viewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ProgressView(value: readingProgress)
|
|
||||||
.progressViewStyle(LinearProgressViewStyle())
|
|
||||||
.frame(height: 3)
|
|
||||||
GeometryReader { geometry in
|
|
||||||
ScrollView {
|
|
||||||
// Invisible GeometryReader to track scroll offset
|
|
||||||
GeometryReader { scrollGeo in
|
|
||||||
Color.clear.preference(
|
|
||||||
key: ScrollOffsetPreferenceKey.self,
|
|
||||||
value: CGPoint(
|
|
||||||
x: scrollGeo.frame(in: .named("scrollView")).minX,
|
|
||||||
y: scrollGeo.frame(in: .named("scrollView")).minY
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.frame(height: 0)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
headerView(width: geometry.size.width)
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
|
||||||
titleSection
|
|
||||||
Divider().padding(.horizontal)
|
|
||||||
if showJumpToProgressButton {
|
|
||||||
JumpButton(containerHeight: geometry.size.height)
|
|
||||||
}
|
|
||||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
|
||||||
WebView(
|
|
||||||
htmlContent: viewModel.articleContent,
|
|
||||||
settings: settings,
|
|
||||||
onHeightChange: { height in
|
|
||||||
if webViewHeight != height {
|
|
||||||
webViewHeight = height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.frame(height: webViewHeight)
|
|
||||||
.cornerRadius(14)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
} else if viewModel.isLoadingArticle {
|
|
||||||
ProgressView("Loading article...")
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
Button(action: {
|
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "safari")
|
|
||||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
|
||||||
}
|
|
||||||
.font(.title3.bold())
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
|
|
||||||
VStack(alignment: .center) {
|
|
||||||
archiveSection
|
|
||||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
|
||||||
.animation(.easeInOut, value: viewModel.articleContent)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invisible marker to measure total content height - placed AFTER all content
|
|
||||||
Color.clear
|
|
||||||
.frame(height: 1)
|
|
||||||
.background(
|
|
||||||
GeometryReader { endGeo in
|
|
||||||
Color.clear.preference(
|
|
||||||
key: ContentHeightPreferenceKey.self,
|
|
||||||
value: endGeo.frame(in: .named("scrollView")).maxY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.coordinateSpace(name: "scrollView")
|
|
||||||
.clipped()
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
.scrollPosition($scrollPosition)
|
|
||||||
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
|
|
||||||
contentEndPosition = endPosition
|
|
||||||
|
|
||||||
let containerHeight = geometry.size.height
|
|
||||||
|
|
||||||
// Update initial position if content grows (WebView still loading) or first time
|
|
||||||
// We always take the maximum position seen (when scrolled to top, this is total content height)
|
|
||||||
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
|
|
||||||
initialContentEndPosition = endPosition
|
|
||||||
print("📏 Content end position updated: \(Int(endPosition)) (container: \(Int(containerHeight)))")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate progress from how much the end marker has moved up
|
|
||||||
guard initialContentEndPosition > 0 else {
|
|
||||||
print("⏳ Waiting for content to load... current: \(Int(endPosition)), container: \(Int(containerHeight))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalScrollableDistance = initialContentEndPosition - containerHeight
|
|
||||||
|
|
||||||
guard totalScrollableDistance > 0 else {
|
|
||||||
print("⚠️ Content not scrollable: initial=\(initialContentEndPosition), container=\(containerHeight)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// How far has the marker moved from its initial position?
|
|
||||||
let scrolled = initialContentEndPosition - endPosition
|
|
||||||
let rawProgress = scrolled / totalScrollableDistance
|
|
||||||
var progress = min(max(rawProgress, 0), 1)
|
|
||||||
|
|
||||||
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
|
|
||||||
if lastSentProgress >= 0.995 {
|
|
||||||
progress = max(progress, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
print("📊 Progress: \(Int(progress * 100))% | scrolled: \(Int(scrolled)) / \(Int(totalScrollableDistance)) | endPos: \(Int(endPosition))")
|
|
||||||
|
|
||||||
// Check if we should update: threshold OR reaching 100% for first time
|
|
||||||
let threshold: Double = 0.03
|
|
||||||
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
|
|
||||||
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
|
|
||||||
|
|
||||||
if shouldUpdate {
|
|
||||||
print("✅ Updating progress: \(Int(lastSentProgress * 100))% → \(Int(progress * 100))%\(reachedEnd ? " [END]" : "")")
|
|
||||||
lastSentProgress = progress
|
|
||||||
readingProgress = progress
|
|
||||||
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
|
|
||||||
// Not needed anymore, we track via ContentHeightPreferenceKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
#if DEBUG
|
|
||||||
// Toggle button (left)
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
Button(action: {
|
|
||||||
useNativeWebView.toggle()
|
|
||||||
}) {
|
|
||||||
Image(systemName: "waveform")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Top toolbar (right)
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button(action: {
|
|
||||||
showingLabelsSheet = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showingFontSettings = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "textformat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingFontSettings) {
|
|
||||||
NavigationView {
|
|
||||||
VStack {
|
|
||||||
FontSettingsView()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.navigationTitle("Font Settings")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Done") {
|
|
||||||
showingFontSettings = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingLabelsSheet) {
|
|
||||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingImageViewer) {
|
|
||||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
|
||||||
}
|
|
||||||
.onChange(of: showingFontSettings) { _, isShowing in
|
|
||||||
if !isShowing {
|
|
||||||
// Reload settings when sheet is dismissed
|
|
||||||
Task {
|
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: showingLabelsSheet) { _, isShowing in
|
|
||||||
if !isShowing {
|
|
||||||
// Reload bookmark detail when labels sheet is dismissed
|
|
||||||
Task {
|
|
||||||
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.readProgress) { _, progress in
|
|
||||||
showJumpToProgressButton = progress > 0 && progress < 100
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
|
||||||
await viewModel.loadArticleContent(id: bookmarkId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ViewBuilder
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func headerView(width: CGFloat) -> some View {
|
|
||||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
|
||||||
ZStack(alignment: .bottomTrailing) {
|
|
||||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: width, height: headerHeight)
|
|
||||||
.clipped()
|
|
||||||
|
|
||||||
// Zoom icon
|
|
||||||
Button(action: {
|
|
||||||
showingImageViewer = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(8)
|
|
||||||
.background(
|
|
||||||
Circle()
|
|
||||||
.fill(Color.black.opacity(0.6))
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 16)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
|
||||||
.frame(height: headerHeight)
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
.onTapGesture {
|
|
||||||
showingImageViewer = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var titleSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(viewModel.bookmarkDetail.title)
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.padding(.bottom, 2)
|
|
||||||
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
|
|
||||||
metaInfoSection
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var contentSection: some View {
|
|
||||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
|
||||||
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
|
|
||||||
withAnimation(.easeInOut(duration: 0.1)) {
|
|
||||||
webViewHeight = height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: webViewHeight)
|
|
||||||
.cornerRadius(14)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
.animation(.easeInOut, value: webViewHeight)
|
|
||||||
} else if viewModel.isLoadingArticle {
|
|
||||||
ProgressView("Loading article...")
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
Button(action: {
|
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "safari")
|
|
||||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
|
||||||
}
|
|
||||||
.font(.title3.bold())
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var metaInfoSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if !viewModel.bookmarkDetail.authors.isEmpty {
|
|
||||||
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
|
||||||
}
|
|
||||||
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
|
||||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
|
|
||||||
|
|
||||||
// Labels section
|
|
||||||
if !viewModel.bookmarkDetail.labels.isEmpty {
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
Image(systemName: "tag")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 2)
|
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
|
|
||||||
Text(label)
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.accentColor.opacity(0.1))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metaRow(icon: "safari") {
|
|
||||||
Button(action: {
|
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
|
||||||
}) {
|
|
||||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if appSettings.enableTTS {
|
|
||||||
metaRow(icon: "speaker.wave.2") {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.addBookmarkToSpeechQueue()
|
|
||||||
playerUIState.showPlayer()
|
|
||||||
}) {
|
|
||||||
Text("Read article aloud")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func metaRow(icon: String, text: String) -> some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: icon)
|
|
||||||
Text(text)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: icon)
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatDate(_ dateString: String) -> String {
|
|
||||||
let isoFormatter = ISO8601DateFormatter()
|
|
||||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
let isoFormatterNoMillis = ISO8601DateFormatter()
|
|
||||||
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
|
|
||||||
var date: Date?
|
|
||||||
if let parsedDate = isoFormatter.date(from: dateString) {
|
|
||||||
date = parsedDate
|
|
||||||
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
|
|
||||||
date = parsedDate
|
|
||||||
}
|
|
||||||
if let date = date {
|
|
||||||
let displayFormatter = DateFormatter()
|
|
||||||
displayFormatter.dateStyle = .medium
|
|
||||||
displayFormatter.timeStyle = .short
|
|
||||||
displayFormatter.locale = .autoupdatingCurrent
|
|
||||||
return displayFormatter.string(from: date)
|
|
||||||
}
|
|
||||||
return dateString
|
|
||||||
}
|
|
||||||
|
|
||||||
private var archiveSection: some View {
|
|
||||||
VStack(alignment: .center, spacing: 12) {
|
|
||||||
Text("Finished reading?")
|
|
||||||
.font(.headline)
|
|
||||||
.padding(.top, 24)
|
|
||||||
VStack(alignment: .center, spacing: 16) {
|
|
||||||
Button(action: {
|
|
||||||
Task {
|
|
||||||
await viewModel.toggleFavorite(id: bookmarkId)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
|
|
||||||
.foregroundColor(viewModel.bookmarkDetail.isMarked ? .yellow : .gray)
|
|
||||||
Text(viewModel.bookmarkDetail.isMarked ? "Favorite" : "Mark as favorite")
|
|
||||||
}
|
|
||||||
.font(.title3.bold())
|
|
||||||
.frame(maxHeight: 60)
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
|
|
||||||
// Archive button
|
|
||||||
Button(action: {
|
|
||||||
Task {
|
|
||||||
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
|
|
||||||
Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark")
|
|
||||||
}
|
|
||||||
.font(.title3.bold())
|
|
||||||
.frame(maxHeight: 60)
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
}
|
|
||||||
if let error = viewModel.errorMessage {
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.footnote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func JumpButton(containerHeight: CGFloat) -> some View {
|
|
||||||
Button(action: {
|
|
||||||
let maxOffset = webViewHeight - containerHeight
|
|
||||||
let offset = maxOffset * (Double(viewModel.readProgress) / 100.0)
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
||||||
scrollPosition = ScrollPosition(y: offset)
|
|
||||||
showJumpToProgressButton = false
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Text("Jump to last read position (\(viewModel.readProgress)%)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.padding(8)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.background(Color.accentColor.opacity(0.15))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding([.top, .horizontal])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NavigationView {
|
|
||||||
BookmarkDetailLegacyView(
|
|
||||||
bookmarkId: "123",
|
|
||||||
useNativeWebView: .constant(false),
|
|
||||||
viewModel: .init(MockUseCaseFactory())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +1,472 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SafariServices
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// Container view that routes to the appropriate BookmarkDetail implementation
|
|
||||||
/// based on iOS version availability or user preference
|
|
||||||
struct BookmarkDetailView: View {
|
struct BookmarkDetailView: View {
|
||||||
let bookmarkId: String
|
let bookmarkId: String
|
||||||
|
|
||||||
@AppStorage("useNativeWebView") private var useNativeWebView: Bool = true
|
// MARK: - States
|
||||||
|
|
||||||
|
@State private var viewModel: BookmarkDetailViewModel
|
||||||
|
@State private var webViewHeight: CGFloat = 300
|
||||||
|
@State private var showingFontSettings = false
|
||||||
|
@State private var showingLabelsSheet = false
|
||||||
|
@State private var readingProgress: Double = 0.0
|
||||||
|
@State private var scrollViewHeight: CGFloat = 1
|
||||||
|
@State private var showJumpToProgressButton: Bool = false
|
||||||
|
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||||
|
@State private var showingImageViewer = false
|
||||||
|
|
||||||
|
// MARK: - Envs
|
||||||
|
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private let headerHeight: CGFloat = 360
|
||||||
|
|
||||||
|
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
|
||||||
|
self.bookmarkId = bookmarkId
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.webViewHeight = webViewHeight
|
||||||
|
self.showingFontSettings = showingFontSettings
|
||||||
|
self.showingLabelsSheet = showingLabelsSheet
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 26.0, *) {
|
VStack(spacing: 0) {
|
||||||
if useNativeWebView {
|
ProgressView(value: readingProgress)
|
||||||
// Use modern SwiftUI-native implementation on iOS 26+
|
.progressViewStyle(LinearProgressViewStyle())
|
||||||
BookmarkDetailView2(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView)
|
.frame(height: 3)
|
||||||
} else {
|
GeometryReader { outerGeo in
|
||||||
// Use legacy WKWebView-based implementation
|
ScrollView {
|
||||||
BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView)
|
VStack(spacing: 0) {
|
||||||
|
GeometryReader { geo in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ScrollOffsetPreferenceKey.self,
|
||||||
|
value: geo.frame(in: .named("scroll")).minY)
|
||||||
}
|
}
|
||||||
} else {
|
.frame(height: 0)
|
||||||
// iOS < 26: always use Legacy
|
ZStack(alignment: .top) {
|
||||||
BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: .constant(false))
|
headerView(geometry: outerGeo)
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
||||||
|
titleSection
|
||||||
|
Divider().padding(.horizontal)
|
||||||
|
if showJumpToProgressButton {
|
||||||
|
JumpButton()
|
||||||
}
|
}
|
||||||
|
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||||
|
WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in
|
||||||
|
if webViewHeight != height {
|
||||||
|
webViewHeight = height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.frame(height: webViewHeight)
|
||||||
|
.cornerRadius(14)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.animation(.easeInOut, value: webViewHeight)
|
||||||
|
} else if viewModel.isLoadingArticle {
|
||||||
|
ProgressView("Loading article...")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "safari")
|
||||||
|
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
||||||
|
}
|
||||||
|
.font(.title3.bold())
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
|
||||||
|
VStack(alignment: .center) {
|
||||||
|
archiveSection
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
.animation(.easeInOut, value: viewModel.articleContent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.coordinateSpace(name: "scroll")
|
||||||
|
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
||||||
|
scrollViewHeight = outerGeo.size.height
|
||||||
|
let maxOffset = webViewHeight - scrollViewHeight
|
||||||
|
let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1)
|
||||||
|
let progress = min(max(rawProgress, 0), 1)
|
||||||
|
readingProgress = progress
|
||||||
|
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.scrollPosition($scrollPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: {
|
||||||
|
showingLabelsSheet = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showingFontSettings = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "textformat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingFontSettings) {
|
||||||
|
NavigationView {
|
||||||
|
VStack {
|
||||||
|
FontSettingsView()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.navigationTitle("Font Settings")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
showingFontSettings = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingLabelsSheet) {
|
||||||
|
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingImageViewer) {
|
||||||
|
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
||||||
|
}
|
||||||
|
.onChange(of: showingFontSettings) { _, isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
// Reload settings when sheet is dismissed
|
||||||
|
Task {
|
||||||
|
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: showingLabelsSheet) { _, isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
// Reload bookmark detail when labels sheet is dismissed
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.readProgress) { _, progress in
|
||||||
|
showJumpToProgressButton = progress > 0 && progress < 100
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||||
|
await viewModel.loadArticleContent(id: bookmarkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewBuilder
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func headerView(geometry: GeometryProxy) -> some View {
|
||||||
|
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let offset = geo.frame(in: .global).minY
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||||
|
.clipped()
|
||||||
|
.offset(y: (offset > 0 ? -offset : 0))
|
||||||
|
|
||||||
|
// Tap area and zoom icon
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
showingImageViewer = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(8)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(0.6))
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: headerHeight + (offset > 0 ? offset : 0))
|
||||||
|
.offset(y: (offset > 0 ? -offset : 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: headerHeight)
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.onTapGesture {
|
||||||
|
showingImageViewer = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(viewModel.bookmarkDetail.title)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
|
||||||
|
metaInfoSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentSection: some View {
|
||||||
|
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||||
|
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
|
||||||
|
withAnimation(.easeInOut(duration: 0.1)) {
|
||||||
|
webViewHeight = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: webViewHeight)
|
||||||
|
.cornerRadius(14)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.animation(.easeInOut, value: webViewHeight)
|
||||||
|
} else if viewModel.isLoadingArticle {
|
||||||
|
ProgressView("Loading article...")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "safari")
|
||||||
|
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||||
|
}
|
||||||
|
.font(.title3.bold())
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var metaInfoSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if !viewModel.bookmarkDetail.authors.isEmpty {
|
||||||
|
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
||||||
|
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
|
||||||
|
|
||||||
|
// Labels section
|
||||||
|
if !viewModel.bookmarkDetail.labels.isEmpty {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 2)
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.accentColor.opacity(0.1))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metaRow(icon: "safari") {
|
||||||
|
Button(action: {
|
||||||
|
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||||
|
}) {
|
||||||
|
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if appSettings.enableTTS {
|
||||||
|
metaRow(icon: "speaker.wave.2") {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.addBookmarkToSpeechQueue()
|
||||||
|
playerUIState.showPlayer()
|
||||||
|
}) {
|
||||||
|
Text("Read article aloud")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func metaRow(icon: String, text: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
Text(text)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ dateString: String) -> String {
|
||||||
|
let isoFormatter = ISO8601DateFormatter()
|
||||||
|
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let isoFormatterNoMillis = ISO8601DateFormatter()
|
||||||
|
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
|
||||||
|
var date: Date?
|
||||||
|
if let parsedDate = isoFormatter.date(from: dateString) {
|
||||||
|
date = parsedDate
|
||||||
|
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
|
||||||
|
date = parsedDate
|
||||||
|
}
|
||||||
|
if let date = date {
|
||||||
|
let displayFormatter = DateFormatter()
|
||||||
|
displayFormatter.dateStyle = .medium
|
||||||
|
displayFormatter.timeStyle = .short
|
||||||
|
displayFormatter.locale = .autoupdatingCurrent
|
||||||
|
return displayFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
return dateString
|
||||||
|
}
|
||||||
|
|
||||||
|
private var archiveSection: some View {
|
||||||
|
VStack(alignment: .center, spacing: 12) {
|
||||||
|
Text("Finished reading?")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.top, 24)
|
||||||
|
VStack(alignment: .center, spacing: 16) {
|
||||||
|
Button(action: {
|
||||||
|
Task {
|
||||||
|
await viewModel.toggleFavorite(id: bookmarkId)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
|
||||||
|
.foregroundColor(viewModel.bookmarkDetail.isMarked ? .yellow : .gray)
|
||||||
|
Text(viewModel.bookmarkDetail.isMarked ? "Favorite" : "Mark as favorite")
|
||||||
|
}
|
||||||
|
.font(.title3.bold())
|
||||||
|
.frame(maxHeight: 60)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
|
|
||||||
|
// Archive button
|
||||||
|
Button(action: {
|
||||||
|
Task {
|
||||||
|
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
|
||||||
|
Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark")
|
||||||
|
}
|
||||||
|
.font(.title3.bold())
|
||||||
|
.frame(maxHeight: 60)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
|
}
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func JumpButton() -> some View {
|
||||||
|
Button(action: {
|
||||||
|
let maxOffset = webViewHeight - scrollViewHeight
|
||||||
|
let offset = maxOffset * (Double(viewModel.readProgress) / 100.0)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
scrollPosition = ScrollPosition(y: offset)
|
||||||
|
showJumpToProgressButton = false
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Jump to last read position (\(viewModel.readProgress)%)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.background(Color.accentColor.opacity(0.15))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding([.top, .horizontal])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||||
|
typealias Value = CGFloat
|
||||||
|
static var defaultValue = CGFloat.zero
|
||||||
|
static func reduce(value: inout Value, nextValue: () -> Value) {
|
||||||
|
value += nextValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
BookmarkDetailView(bookmarkId: "123")
|
BookmarkDetailView(bookmarkId: "123",
|
||||||
|
viewModel: .init(MockUseCaseFactory()),
|
||||||
|
webViewHeight: 300,
|
||||||
|
showingFontSettings: false,
|
||||||
|
showingLabelsSheet: false,
|
||||||
|
playerUIState: .init())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,518 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import SafariServices
|
|
||||||
|
|
||||||
@available(iOS 26.0, *)
|
|
||||||
struct BookmarkDetailView2: View {
|
|
||||||
let bookmarkId: String
|
|
||||||
@Binding var useNativeWebView: Bool
|
|
||||||
|
|
||||||
// MARK: - States
|
|
||||||
|
|
||||||
@State private var viewModel: BookmarkDetailViewModel
|
|
||||||
@State private var webViewHeight: CGFloat = 300
|
|
||||||
@State private var contentEndPosition: CGFloat = 0
|
|
||||||
@State private var initialContentEndPosition: CGFloat = 0
|
|
||||||
@State private var showingFontSettings = false
|
|
||||||
@State private var showingLabelsSheet = false
|
|
||||||
@State private var readingProgress: Double = 0.0
|
|
||||||
@State private var lastSentProgress: Double = 0.0
|
|
||||||
@State private var showJumpToProgressButton: Bool = false
|
|
||||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
|
||||||
@State private var showingImageViewer = false
|
|
||||||
|
|
||||||
// MARK: - Envs
|
|
||||||
|
|
||||||
@EnvironmentObject var playerUIState: PlayerUIState
|
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
private let headerHeight: CGFloat = 360
|
|
||||||
|
|
||||||
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
|
|
||||||
self.bookmarkId = bookmarkId
|
|
||||||
self._useNativeWebView = useNativeWebView
|
|
||||||
self.viewModel = viewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
mainView
|
|
||||||
}
|
|
||||||
|
|
||||||
private var mainView: some View {
|
|
||||||
content
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
toolbarContent
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingFontSettings) {
|
|
||||||
fontSettingsSheet
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingLabelsSheet) {
|
|
||||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingImageViewer) {
|
|
||||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
|
||||||
}
|
|
||||||
.onChange(of: showingFontSettings) { _, isShowing in
|
|
||||||
if !isShowing {
|
|
||||||
Task {
|
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: showingLabelsSheet) { _, isShowing in
|
|
||||||
if !isShowing {
|
|
||||||
Task {
|
|
||||||
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.readProgress) { _, progress in
|
|
||||||
showJumpToProgressButton = progress > 0 && progress < 100
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
|
||||||
await viewModel.loadArticleContent(id: bookmarkId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var content: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Progress bar at top
|
|
||||||
ProgressView(value: readingProgress)
|
|
||||||
.progressViewStyle(LinearProgressViewStyle())
|
|
||||||
.frame(height: 3)
|
|
||||||
|
|
||||||
// Main scroll content
|
|
||||||
scrollViewContent
|
|
||||||
.overlay(alignment: .bottomTrailing) {
|
|
||||||
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
|
|
||||||
if readingProgress >= 0.9 {
|
|
||||||
floatingActionButtons
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: readingProgress >= 0.9)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var floatingActionButtons: some View {
|
|
||||||
GlassEffectContainer(spacing: 52.0) {
|
|
||||||
HStack(spacing: 52.0) {
|
|
||||||
Button(action: {
|
|
||||||
Task {
|
|
||||||
await viewModel.toggleFavorite(id: bookmarkId)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
|
|
||||||
.foregroundStyle(viewModel.bookmarkDetail.isMarked ? .yellow : .primary)
|
|
||||||
.frame(width: 52.0, height: 52.0)
|
|
||||||
.font(.system(size: 31))
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
.glassEffect()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
Task {
|
|
||||||
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
|
|
||||||
.frame(width: 52.0, height: 52.0)
|
|
||||||
.font(.system(size: 31))
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
.glassEffect()
|
|
||||||
.offset(x: -52.0, y: 0.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 1)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var scrollViewContent: some View {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
ScrollView {
|
|
||||||
// Invisible GeometryReader to track scroll offset
|
|
||||||
GeometryReader { scrollGeo in
|
|
||||||
Color.clear.preference(
|
|
||||||
key: ScrollOffsetPreferenceKey.self,
|
|
||||||
value: CGPoint(
|
|
||||||
x: scrollGeo.frame(in: .named("scrollView")).minX,
|
|
||||||
y: scrollGeo.frame(in: .named("scrollView")).minY
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.frame(height: 0)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
headerView(width: geometry.size.width)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
|
||||||
|
|
||||||
titleSection
|
|
||||||
|
|
||||||
Divider().padding(.horizontal)
|
|
||||||
|
|
||||||
if showJumpToProgressButton {
|
|
||||||
jumpButton(containerHeight: geometry.size.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Article content (WebView)
|
|
||||||
articleContent
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invisible marker to measure total content height - placed AFTER all content
|
|
||||||
Color.clear
|
|
||||||
.frame(height: 1)
|
|
||||||
.background(
|
|
||||||
GeometryReader { endGeo in
|
|
||||||
Color.clear.preference(
|
|
||||||
key: ContentHeightPreferenceKey.self,
|
|
||||||
value: endGeo.frame(in: .named("scrollView")).maxY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.coordinateSpace(name: "scrollView")
|
|
||||||
.clipped()
|
|
||||||
.ignoresSafeArea(edges: [.top, .bottom])
|
|
||||||
.scrollPosition($scrollPosition)
|
|
||||||
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
|
|
||||||
contentEndPosition = endPosition
|
|
||||||
|
|
||||||
let containerHeight = geometry.size.height
|
|
||||||
|
|
||||||
// Update initial position if content grows (WebView still loading) or first time
|
|
||||||
// We always take the maximum position seen (when scrolled to top, this is total content height)
|
|
||||||
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
|
|
||||||
initialContentEndPosition = endPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate progress from how much the end marker has moved up
|
|
||||||
guard initialContentEndPosition > 0 else { return }
|
|
||||||
|
|
||||||
let totalScrollableDistance = initialContentEndPosition - containerHeight
|
|
||||||
|
|
||||||
guard totalScrollableDistance > 0 else { return }
|
|
||||||
|
|
||||||
// How far has the marker moved from its initial position?
|
|
||||||
let scrolled = initialContentEndPosition - endPosition
|
|
||||||
let rawProgress = scrolled / totalScrollableDistance
|
|
||||||
var progress = min(max(rawProgress, 0), 1)
|
|
||||||
|
|
||||||
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
|
|
||||||
if lastSentProgress >= 0.995 {
|
|
||||||
progress = max(progress, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should update: threshold OR reaching 100% for first time
|
|
||||||
let threshold: Double = 0.03
|
|
||||||
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
|
|
||||||
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
|
|
||||||
|
|
||||||
readingProgress = progress
|
|
||||||
|
|
||||||
if shouldUpdate {
|
|
||||||
lastSentProgress = progress
|
|
||||||
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
|
|
||||||
// Not needed anymore, we track via ContentHeightPreferenceKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
|
||||||
private var toolbarContent: some ToolbarContent {
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// Toggle button (left)
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
Button(action: {
|
|
||||||
useNativeWebView.toggle()
|
|
||||||
}) {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Top toolbar (right)
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button(action: {
|
|
||||||
showingLabelsSheet = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showingFontSettings = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "textformat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fontSettingsSheet: some View {
|
|
||||||
NavigationView {
|
|
||||||
VStack {
|
|
||||||
FontSettingsView()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.navigationTitle("Font Settings")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Done") {
|
|
||||||
showingFontSettings = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ViewBuilder
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func headerView(width: CGFloat) -> some View {
|
|
||||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
|
||||||
ZStack(alignment: .bottomTrailing) {
|
|
||||||
// Background blur for images that don't fill
|
|
||||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: width, height: headerHeight)
|
|
||||||
.blur(radius: 30)
|
|
||||||
.clipped()
|
|
||||||
|
|
||||||
// Main image with fit
|
|
||||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(width: width, height: headerHeight)
|
|
||||||
|
|
||||||
// Zoom icon
|
|
||||||
Button(action: {
|
|
||||||
showingImageViewer = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(8)
|
|
||||||
.background(
|
|
||||||
Circle()
|
|
||||||
.fill(Color.black.opacity(0.6))
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 16)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
|
||||||
.frame(width: width, height: headerHeight)
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
.onTapGesture {
|
|
||||||
showingImageViewer = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var titleSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(viewModel.bookmarkDetail.title)
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.padding(.bottom, 2)
|
|
||||||
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
|
|
||||||
metaInfoSection
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var metaInfoSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if !viewModel.bookmarkDetail.authors.isEmpty {
|
|
||||||
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
|
||||||
}
|
|
||||||
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
|
||||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
|
|
||||||
|
|
||||||
// Labels section
|
|
||||||
if !viewModel.bookmarkDetail.labels.isEmpty {
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
Image(systemName: "tag")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 2)
|
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
|
|
||||||
Text(label)
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.accentColor.opacity(0.1))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metaRow(icon: "safari") {
|
|
||||||
Button(action: {
|
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
|
||||||
}) {
|
|
||||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if appSettings.enableTTS {
|
|
||||||
metaRow(icon: "speaker.wave.2") {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.addBookmarkToSpeechQueue()
|
|
||||||
playerUIState.showPlayer()
|
|
||||||
}) {
|
|
||||||
Text("Read article aloud")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func metaRow(icon: String, text: String) -> some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: icon)
|
|
||||||
Text(text)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: icon)
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var articleContent: some View {
|
|
||||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
NativeWebView(
|
|
||||||
htmlContent: viewModel.articleContent,
|
|
||||||
settings: settings,
|
|
||||||
onHeightChange: { height in
|
|
||||||
if webViewHeight != height {
|
|
||||||
webViewHeight = height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.frame(height: webViewHeight)
|
|
||||||
.cornerRadius(14)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
} else if viewModel.isLoadingArticle {
|
|
||||||
ProgressView("Loading article...")
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
Button(action: {
|
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "safari")
|
|
||||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
|
||||||
}
|
|
||||||
.font(.title3.bold())
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func jumpButton(containerHeight: CGFloat) -> some View {
|
|
||||||
Button(action: {
|
|
||||||
let maxOffset = webViewHeight - containerHeight
|
|
||||||
let offset = maxOffset * (Double(viewModel.readProgress) / 100.0)
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
||||||
scrollPosition = ScrollPosition(y: offset)
|
|
||||||
showJumpToProgressButton = false
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Text("Jump to last read position (\(viewModel.readProgress)%)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.padding(8)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.background(Color.accentColor.opacity(0.15))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding([.top, .horizontal])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatDate(_ dateString: String) -> String {
|
|
||||||
let isoFormatter = ISO8601DateFormatter()
|
|
||||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
let isoFormatterNoMillis = ISO8601DateFormatter()
|
|
||||||
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
|
|
||||||
var date: Date?
|
|
||||||
if let parsedDate = isoFormatter.date(from: dateString) {
|
|
||||||
date = parsedDate
|
|
||||||
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
|
|
||||||
date = parsedDate
|
|
||||||
}
|
|
||||||
if let date = date {
|
|
||||||
let displayFormatter = DateFormatter()
|
|
||||||
displayFormatter.dateStyle = .medium
|
|
||||||
displayFormatter.timeStyle = .short
|
|
||||||
displayFormatter.locale = .autoupdatingCurrent
|
|
||||||
return displayFormatter.string(from: date)
|
|
||||||
}
|
|
||||||
return dateString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
NavigationView {
|
|
||||||
BookmarkDetailView2(
|
|
||||||
bookmarkId: "123",
|
|
||||||
useNativeWebView: .constant(true),
|
|
||||||
viewModel: .init(MockUseCaseFactory())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -73,12 +73,15 @@ class BookmarkLabelsViewModel {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func addLabel(to bookmarkId: String, label: String) async {
|
func addLabel(to bookmarkId: String, label: String) async {
|
||||||
let splitLabels = LabelUtils.splitLabelsFromInput(label)
|
let individualLabels = label
|
||||||
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels)
|
.components(separatedBy: " ")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.filter { !currentLabels.contains($0) }
|
||||||
|
|
||||||
guard !uniqueLabels.isEmpty else { return }
|
guard !individualLabels.isEmpty else { return }
|
||||||
|
|
||||||
await addLabels(to: bookmarkId, labels: uniqueLabels)
|
await addLabels(to: bookmarkId, labels: individualLabels)
|
||||||
newLabelText = ""
|
newLabelText = ""
|
||||||
searchText = ""
|
searchText = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ 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
|
||||||
@ -256,7 +255,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 {
|
||||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
SafariUtil.openInSafari(url: bookmark.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -337,7 +336,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 {
|
||||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
SafariUtil.openInSafari(url: bookmark.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,7 +56,6 @@ struct BookmarksView: View {
|
|||||||
)
|
)
|
||||||
) { bookmarkId in
|
) { bookmarkId in
|
||||||
BookmarkDetailView(bookmarkId: bookmarkId)
|
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||||
.toolbar(.hidden, for: .tabBar)
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddBookmark) {
|
.sheet(isPresented: $showingAddBookmark) {
|
||||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||||
|
|||||||
@ -1,309 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
// MARK: - iOS 26+ Native SwiftUI WebView Implementation
|
|
||||||
// This implementation is available but not currently used
|
|
||||||
// To activate: Replace WebView usage with hybrid approach using #available(iOS 26.0, *)
|
|
||||||
|
|
||||||
@available(iOS 26.0, *)
|
|
||||||
struct NativeWebView: View {
|
|
||||||
let htmlContent: String
|
|
||||||
let settings: Settings
|
|
||||||
let onHeightChange: (CGFloat) -> Void
|
|
||||||
var onScroll: ((Double) -> Void)? = nil
|
|
||||||
|
|
||||||
@State private var webPage = WebPage()
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
WebKit.WebView(webPage)
|
|
||||||
.scrollDisabled(true) // Disable internal scrolling
|
|
||||||
.onAppear {
|
|
||||||
loadStyledContent()
|
|
||||||
}
|
|
||||||
.onChange(of: htmlContent) { _, _ in
|
|
||||||
loadStyledContent()
|
|
||||||
}
|
|
||||||
.onChange(of: colorScheme) { _, _ in
|
|
||||||
loadStyledContent()
|
|
||||||
}
|
|
||||||
.onChange(of: webPage.isLoading) { _, isLoading in
|
|
||||||
if !isLoading {
|
|
||||||
// Update height when content finishes loading
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
||||||
Task {
|
|
||||||
await updateContentHeightWithJS()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateContentHeightWithJS() async {
|
|
||||||
var lastHeight: CGFloat = 0
|
|
||||||
|
|
||||||
// Similar strategy to WebView: multiple attempts with increasing delays
|
|
||||||
let delays = [0.1, 0.2, 0.5, 1.0, 1.5, 2.0] // 6 attempts like WebView
|
|
||||||
|
|
||||||
for (index, delay) in delays.enumerated() {
|
|
||||||
let attempt = index + 1
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
||||||
|
|
||||||
do {
|
|
||||||
// Try to get height via JavaScript - use simple document.body.scrollHeight
|
|
||||||
let result = try await webPage.callJavaScript("return document.body.scrollHeight")
|
|
||||||
|
|
||||||
if let height = result as? Double, height > 0 {
|
|
||||||
let cgHeight = CGFloat(height)
|
|
||||||
|
|
||||||
// Update height if it's significantly different (> 5px like WebView)
|
|
||||||
if lastHeight == 0 || abs(cgHeight - lastHeight) > 5 {
|
|
||||||
print("🟢 NativeWebView - JavaScript height updated: \(height)px on attempt \(attempt)")
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onHeightChange(cgHeight)
|
|
||||||
}
|
|
||||||
lastHeight = cgHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
// If height seems stable (no change in last 2 attempts), we can exit early
|
|
||||||
if attempt >= 2 && lastHeight > 0 {
|
|
||||||
print("🟢 NativeWebView - Height stabilized at \(lastHeight)px after \(attempt) attempts")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("🟡 NativeWebView - JavaScript attempt \(attempt) failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no valid height was found, use fallback
|
|
||||||
if lastHeight == 0 {
|
|
||||||
print("🔴 NativeWebView - No valid JavaScript height found, using fallback")
|
|
||||||
updateContentHeightFallback()
|
|
||||||
} else {
|
|
||||||
print("🟢 NativeWebView - Final height: \(lastHeight)px")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateContentHeightFallback() {
|
|
||||||
// Simplified fallback calculation
|
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
|
||||||
let plainText = htmlContent.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
|
||||||
let characterCount = plainText.count
|
|
||||||
let estimatedLines = max(1, characterCount / 80)
|
|
||||||
let textHeight = CGFloat(estimatedLines) * CGFloat(fontSize) * 1.8
|
|
||||||
let finalHeight = max(400, min(textHeight + 100, 3000))
|
|
||||||
|
|
||||||
print("🟡 NativeWebView - Using fallback height: \(finalHeight)px")
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onHeightChange(finalHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadStyledContent() {
|
|
||||||
let isDarkMode = colorScheme == .dark
|
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
|
||||||
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
|
|
||||||
|
|
||||||
let styledHTML = """
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
overflow-x: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: \(fontFamily);
|
|
||||||
line-height: 1.8;
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px;
|
|
||||||
background-color: \(isDarkMode ? "#000000" : "#ffffff");
|
|
||||||
color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
|
|
||||||
font-size: \(fontSize)px;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-webkit-user-select: text;
|
|
||||||
user-select: text;
|
|
||||||
overflow-x: hidden;
|
|
||||||
width: 100%;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
|
||||||
margin-top: 24px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: \(fontSize * 3 / 2)px; }
|
|
||||||
h2 { font-size: \(fontSize * 5 / 4)px; }
|
|
||||||
h3 { font-size: \(fontSize * 9 / 8)px; }
|
|
||||||
|
|
||||||
p { margin-bottom: 16px; }
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
a { color: \(isDarkMode ? "#0A84FF" : "#007AFF"); text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 4px solid \(isDarkMode ? "#0A84FF" : "#007AFF");
|
|
||||||
margin: 16px 0;
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-style: italic;
|
|
||||||
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)");
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
|
|
||||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'SF Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
|
|
||||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
font-family: 'SF Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol { padding-left: 20px; margin-bottom: 16px; }
|
|
||||||
li { margin-bottom: 4px; }
|
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
|
||||||
th, td { border: 1px solid #ccc; padding: 8px 12px; text-align: left; }
|
|
||||||
th { font-weight: 600; }
|
|
||||||
|
|
||||||
hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
\(htmlContent)
|
|
||||||
<script>
|
|
||||||
function measureHeight() {
|
|
||||||
return Math.max(
|
|
||||||
document.body.scrollHeight || 0,
|
|
||||||
document.body.offsetHeight || 0,
|
|
||||||
document.documentElement.clientHeight || 0,
|
|
||||||
document.documentElement.scrollHeight || 0,
|
|
||||||
document.documentElement.offsetHeight || 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make function globally available
|
|
||||||
window.getContentHeight = measureHeight;
|
|
||||||
|
|
||||||
// Auto-measure when everything is ready
|
|
||||||
function scheduleHeightCheck() {
|
|
||||||
// Multiple timing strategies
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', delayedHeightCheck);
|
|
||||||
} else {
|
|
||||||
delayedHeightCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check after images load
|
|
||||||
window.addEventListener('load', delayedHeightCheck);
|
|
||||||
|
|
||||||
// Force check after layout
|
|
||||||
setTimeout(delayedHeightCheck, 50);
|
|
||||||
setTimeout(delayedHeightCheck, 100);
|
|
||||||
setTimeout(delayedHeightCheck, 200);
|
|
||||||
setTimeout(delayedHeightCheck, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function delayedHeightCheck() {
|
|
||||||
// Force layout recalculation
|
|
||||||
document.body.offsetHeight;
|
|
||||||
const height = measureHeight();
|
|
||||||
console.log('NativeWebView height check:', height);
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleHeightCheck();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
webPage.load(html: styledHTML)
|
|
||||||
|
|
||||||
// Update height after content loads
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
Task {
|
|
||||||
await updateContentHeightWithJS()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getFontSize(from fontSize: FontSize) -> Int {
|
|
||||||
switch fontSize {
|
|
||||||
case .small: return 14
|
|
||||||
case .medium: return 16
|
|
||||||
case .large: return 18
|
|
||||||
case .extraLarge: return 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getFontFamily(from fontFamily: FontFamily) -> String {
|
|
||||||
switch fontFamily {
|
|
||||||
case .system: return "-apple-system, BlinkMacSystemFont, sans-serif"
|
|
||||||
case .serif: return "'Times New Roman', Times, serif"
|
|
||||||
case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif"
|
|
||||||
case .monospace: return "'SF Mono', Menlo, Monaco, monospace"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Hybrid WebView (Not Currently Used)
|
|
||||||
// This would be the implementation to use both native and legacy WebViews
|
|
||||||
// Currently commented out - the app uses only the crash-resistant WebView
|
|
||||||
|
|
||||||
/*
|
|
||||||
struct HybridWebView: View {
|
|
||||||
let htmlContent: String
|
|
||||||
let settings: Settings
|
|
||||||
let onHeightChange: (CGFloat) -> Void
|
|
||||||
var onScroll: ((Double) -> Void)? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
// Use new native SwiftUI WebView on iOS 26+
|
|
||||||
NativeWebView(
|
|
||||||
htmlContent: htmlContent,
|
|
||||||
settings: settings,
|
|
||||||
onHeightChange: onHeightChange,
|
|
||||||
onScroll: onScroll
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Fallback to crash-resistant WebView for older iOS
|
|
||||||
WebView(
|
|
||||||
htmlContent: htmlContent,
|
|
||||||
settings: settings,
|
|
||||||
onHeightChange: onHeightChange,
|
|
||||||
onScroll: onScroll
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
@ -26,6 +26,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.allowsBackForwardNavigationGestures = false
|
webView.allowsBackForwardNavigationGestures = false
|
||||||
webView.allowsLinkPreview = true
|
webView.allowsLinkPreview = true
|
||||||
|
|
||||||
|
// Message Handler hier einmalig hinzufügen
|
||||||
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
|
||||||
@ -35,29 +36,16 @@ struct WebView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
|
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
|
||||||
let isDarkMode = colorScheme == .dark
|
let isDarkMode = colorScheme == .dark
|
||||||
|
|
||||||
|
// Font Settings aus Settings-Objekt
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||||
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
|
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
|
||||||
|
|
||||||
// Clean up problematic HTML that kills performance
|
|
||||||
let cleanedHTML = htmlContent
|
|
||||||
// Remove Google attributes that cause navigation events
|
|
||||||
.replacingOccurrences(of: #"\s*jsaction="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
.replacingOccurrences(of: #"\s*jscontroller="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
.replacingOccurrences(of: #"\s*jsname="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
// Remove unnecessary IDs that bloat the DOM
|
|
||||||
.replacingOccurrences(of: #"\s*id="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
// Remove tabindex from non-interactive elements
|
|
||||||
.replacingOccurrences(of: #"\s*tabindex="[^"]*""#, with: "", options: .regularExpression)
|
|
||||||
// Remove role=button from figures (causes false click targets)
|
|
||||||
.replacingOccurrences(of: #"\s*role="button""#, with: "", options: .regularExpression)
|
|
||||||
// Fix invalid nested <p> tags inside <pre><span>
|
|
||||||
.replacingOccurrences(of: #"<pre><span[^>]*>([^<]*)<p>"#, with: "<pre><span>$1\n", options: .regularExpression)
|
|
||||||
.replacingOccurrences(of: #"</p>([^<]*)</span></pre>"#, with: "\n$1</span></pre>", options: .regularExpression)
|
|
||||||
|
|
||||||
let styledHTML = """
|
let styledHTML = """
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@ -238,31 +226,26 @@ struct WebView: UIViewRepresentable {
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
\(cleanedHTML)
|
\(htmlContent)
|
||||||
<script>
|
<script>
|
||||||
let lastHeight = 0;
|
|
||||||
let heightUpdateTimeout = null;
|
|
||||||
let scrollTimeout = null;
|
|
||||||
let isScrolling = false;
|
|
||||||
|
|
||||||
function updateHeight() {
|
function updateHeight() {
|
||||||
const height = document.body.scrollHeight;
|
const height = document.body.scrollHeight;
|
||||||
if (Math.abs(height - lastHeight) > 5 && !isScrolling) {
|
|
||||||
lastHeight = height;
|
|
||||||
window.webkit.messageHandlers.heightUpdate.postMessage(height);
|
window.webkit.messageHandlers.heightUpdate.postMessage(height);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function debouncedHeightUpdate() {
|
|
||||||
clearTimeout(heightUpdateTimeout);
|
|
||||||
heightUpdateTimeout = setTimeout(updateHeight, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('load', updateHeight);
|
window.addEventListener('load', updateHeight);
|
||||||
setTimeout(updateHeight, 500);
|
setTimeout(updateHeight, 500);
|
||||||
|
|
||||||
|
// Höhe bei Bild-Ladevorgängen aktualisieren
|
||||||
document.querySelectorAll('img').forEach(img => {
|
document.querySelectorAll('img').forEach(img => {
|
||||||
img.addEventListener('load', debouncedHeightUpdate);
|
img.addEventListener('load', updateHeight);
|
||||||
|
});
|
||||||
|
// Scroll progress reporting
|
||||||
|
window.addEventListener('scroll', function() {
|
||||||
|
var scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||||
|
var docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||||||
|
var progress = docHeight > 0 ? scrollTop / docHeight : 0;
|
||||||
|
window.webkit.messageHandlers.scrollProgress.postMessage(progress);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@ -271,15 +254,6 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.loadHTMLString(styledHTML, baseURL: nil)
|
webView.loadHTMLString(styledHTML, baseURL: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismantleUIView(_ webView: WKWebView, coordinator: WebViewCoordinator) {
|
|
||||||
webView.stopLoading()
|
|
||||||
webView.navigationDelegate = nil
|
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
|
||||||
webView.loadHTMLString("", baseURL: nil)
|
|
||||||
coordinator.cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> WebViewCoordinator {
|
func makeCoordinator() -> WebViewCoordinator {
|
||||||
WebViewCoordinator()
|
WebViewCoordinator()
|
||||||
}
|
}
|
||||||
@ -308,27 +282,9 @@ struct WebView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||||||
// Callbacks
|
|
||||||
var onHeightChange: ((CGFloat) -> Void)?
|
var onHeightChange: ((CGFloat) -> Void)?
|
||||||
var onScroll: ((Double) -> Void)?
|
var onScroll: ((Double) -> Void)?
|
||||||
|
var hasHeightUpdate: Bool = false
|
||||||
// Height management
|
|
||||||
var lastHeight: CGFloat = 0
|
|
||||||
var pendingHeight: CGFloat = 0
|
|
||||||
var heightUpdateTimer: Timer?
|
|
||||||
|
|
||||||
// Scroll management
|
|
||||||
var isScrolling: Bool = false
|
|
||||||
var scrollVelocity: Double = 0
|
|
||||||
var lastScrollTime: Date = Date()
|
|
||||||
var scrollEndTimer: Timer?
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
private var isCleanedUp = false
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
if navigationAction.navigationType == .linkActivated {
|
if navigationAction.navigationType == .linkActivated {
|
||||||
@ -344,88 +300,16 @@ 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 {
|
if message.name == "heightUpdate", let height = message.body as? CGFloat {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.handleHeightUpdate(height: height)
|
if self.hasHeightUpdate == false {
|
||||||
|
self.onHeightChange?(height)
|
||||||
|
self.hasHeightUpdate = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if message.name == "scrollProgress", let progress = message.body as? Double {
|
if message.name == "scrollProgress", let progress = message.body as? Double {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.handleScrollProgress(progress: progress)
|
self.onScroll?(progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleHeightUpdate(height: CGFloat) {
|
|
||||||
// Store the pending height
|
|
||||||
pendingHeight = height
|
|
||||||
|
|
||||||
// If we're actively scrolling, defer the height update
|
|
||||||
if isScrolling {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply height update immediately if not scrolling
|
|
||||||
applyHeightUpdate(height: height)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleScrollProgress(progress: Double) {
|
|
||||||
let now = Date()
|
|
||||||
let timeDelta = now.timeIntervalSince(lastScrollTime)
|
|
||||||
|
|
||||||
// Calculate scroll velocity to detect fast scrolling
|
|
||||||
if timeDelta > 0 {
|
|
||||||
scrollVelocity = abs(progress) / timeDelta
|
|
||||||
}
|
|
||||||
|
|
||||||
lastScrollTime = now
|
|
||||||
isScrolling = true
|
|
||||||
|
|
||||||
// Longer delay for scroll end detection, especially during fast scrolling
|
|
||||||
let scrollEndDelay: TimeInterval = scrollVelocity > 2.0 ? 0.8 : 0.5
|
|
||||||
|
|
||||||
scrollEndTimer?.invalidate()
|
|
||||||
scrollEndTimer = Timer.scheduledTimer(withTimeInterval: scrollEndDelay, repeats: false) { [weak self] _ in
|
|
||||||
self?.handleScrollEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
onScroll?(progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleScrollEnd() {
|
|
||||||
isScrolling = false
|
|
||||||
scrollVelocity = 0
|
|
||||||
|
|
||||||
// Apply any pending height update after scrolling ends
|
|
||||||
if pendingHeight != lastHeight && pendingHeight > 0 {
|
|
||||||
// Add small delay to ensure scroll has fully stopped
|
|
||||||
heightUpdateTimer?.invalidate()
|
|
||||||
heightUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.applyHeightUpdate(height: self.pendingHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyHeightUpdate(height: CGFloat) {
|
|
||||||
// Only update if height actually changed significantly
|
|
||||||
let heightDifference = abs(height - lastHeight)
|
|
||||||
if heightDifference < 5 { // Ignore tiny height changes that cause flicker
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lastHeight = height
|
|
||||||
onHeightChange?(height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanup() {
|
|
||||||
guard !isCleanedUp else { return }
|
|
||||||
isCleanedUp = true
|
|
||||||
|
|
||||||
scrollEndTimer?.invalidate()
|
|
||||||
scrollEndTimer = nil
|
|
||||||
heightUpdateTimer?.invalidate()
|
|
||||||
heightUpdateTimer = nil
|
|
||||||
|
|
||||||
onHeightChange = nil
|
|
||||||
onScroll = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,7 +150,6 @@ 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 {
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
// Created by Ilyas Hallak on 01.07.25.
|
// Created by Ilyas Hallak on 01.07.25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum BookmarkState: String, CaseIterable {
|
enum BookmarkState: String, CaseIterable {
|
||||||
case all = "all"
|
case all = "all"
|
||||||
case unread = "unread"
|
case unread = "unread"
|
||||||
@ -16,13 +14,13 @@ enum BookmarkState: String, CaseIterable {
|
|||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
return NSLocalizedString("All", comment: "")
|
return "All"
|
||||||
case .unread:
|
case .unread:
|
||||||
return NSLocalizedString("Unread", comment: "")
|
return "Unread"
|
||||||
case .favorite:
|
case .favorite:
|
||||||
return NSLocalizedString("Favorites", comment: "")
|
return "Favorites"
|
||||||
case .archived:
|
case .archived:
|
||||||
return NSLocalizedString("Archive", comment: "")
|
return "Archive"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -103,7 +103,6 @@ struct PadSidebarView: View {
|
|||||||
case .tags:
|
case .tags:
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LabelsView(selectedTag: $selectedTag)
|
LabelsView(selectedTag: $selectedTag)
|
||||||
}
|
|
||||||
.navigationDestination(item: $selectedTag) { label in
|
.navigationDestination(item: $selectedTag) { label in
|
||||||
BookmarksView(state: .all, type: [], selectedBookmark: $selectedBookmark, tag: label.name)
|
BookmarksView(state: .all, type: [], selectedBookmark: $selectedBookmark, tag: label.name)
|
||||||
.navigationTitle("\(label.name) (\(label.count))")
|
.navigationTitle("\(label.name) (\(label.count))")
|
||||||
@ -113,6 +112,7 @@ struct PadSidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.navigationTitle(selectedTab.label)
|
.navigationTitle(selectedTab.label)
|
||||||
}
|
}
|
||||||
} detail: {
|
} detail: {
|
||||||
|
|||||||
@ -9,181 +9,56 @@ import SwiftUI
|
|||||||
|
|
||||||
struct PhoneTabView: View {
|
struct PhoneTabView: View {
|
||||||
private let mainTabs: [SidebarTab] = [.all, .unread, .favorite, .archived]
|
private let mainTabs: [SidebarTab] = [.all, .unread, .favorite, .archived]
|
||||||
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings]
|
||||||
|
|
||||||
@State private var selectedTab: SidebarTab = .unread
|
@State private var selectedMoreTab: SidebarTab? = nil
|
||||||
|
@State private var selectedTabIndex: Int = 1
|
||||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
||||||
|
|
||||||
// Navigation paths for each tab
|
|
||||||
@State private var allPath = NavigationPath()
|
|
||||||
@State private var unreadPath = NavigationPath()
|
|
||||||
@State private var favoritePath = NavigationPath()
|
|
||||||
@State private var archivedPath = NavigationPath()
|
|
||||||
@State private var morePath = NavigationPath()
|
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
@State private var searchViewModel = SearchBookmarksViewModel()
|
|
||||||
@FocusState private var searchFieldIsFocused: Bool
|
|
||||||
|
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
|
|
||||||
private var cardLayoutStyle: CardLayoutStyle {
|
|
||||||
appSettings.settings?.cardLayoutStyle ?? .compact
|
|
||||||
}
|
|
||||||
|
|
||||||
private var offlineBookmarksBadgeCount: Int {
|
|
||||||
offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GlobalPlayerContainerView {
|
|
||||||
TabView(selection: $selectedTab) {
|
|
||||||
|
|
||||||
Tab(value: SidebarTab.all) {
|
|
||||||
NavigationStack(path: $allPath) {
|
|
||||||
tabView(for: .all)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label(SidebarTab.all.label, systemImage: SidebarTab.all.systemImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
Tab(value: SidebarTab.unread) {
|
|
||||||
NavigationStack(path: $unreadPath) {
|
|
||||||
tabView(for: .unread)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label(SidebarTab.unread.label, systemImage: SidebarTab.unread.systemImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
Tab(value: SidebarTab.favorite) {
|
|
||||||
NavigationStack(path: $favoritePath) {
|
|
||||||
tabView(for: .favorite)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label(SidebarTab.favorite.label, systemImage: SidebarTab.favorite.systemImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
Tab(value: SidebarTab.archived) {
|
|
||||||
NavigationStack(path: $archivedPath) {
|
|
||||||
tabView(for: .archived)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label(SidebarTab.archived.label, systemImage: SidebarTab.archived.systemImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// iOS 26+: Dedicated search tab with role
|
|
||||||
if #available(iOS 26, *) {
|
|
||||||
Tab("Search", systemImage: SidebarTab.search.systemImage, value: SidebarTab.search, role: .search) {
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
GlobalPlayerContainerView {
|
||||||
|
TabView(selection: $selectedTabIndex) {
|
||||||
|
mainTabsContent
|
||||||
moreTabContent
|
moreTabContent
|
||||||
.searchable(text: $searchViewModel.searchQuery, prompt: "Search bookmarks...")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.badge(offlineBookmarksBadgeCount)
|
|
||||||
} else {
|
|
||||||
Tab(value: SidebarTab.settings) {
|
|
||||||
NavigationStack(path: $morePath) {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
|
|
||||||
// Classic search bar for iOS 18
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "magnifyingglass")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
TextField("Search...", text: $searchViewModel.searchQuery)
|
|
||||||
.focused($searchFieldIsFocused)
|
|
||||||
.textFieldStyle(PlainTextFieldStyle())
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
if !searchViewModel.searchQuery.isEmpty {
|
|
||||||
Button(action: {
|
|
||||||
searchViewModel.searchQuery = ""
|
|
||||||
searchFieldIsFocused = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.background(Color(.systemGray6))
|
|
||||||
.cornerRadius(12)
|
|
||||||
.padding([.horizontal, .top])
|
|
||||||
|
|
||||||
moreTabContent
|
|
||||||
moreTabsFooter
|
|
||||||
}
|
|
||||||
.navigationTitle("More")
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("More", systemImage: "ellipsis")
|
|
||||||
}
|
|
||||||
.badge(offlineBookmarksBadgeCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tabBarMinimizeBehaviorIfAvailable()
|
|
||||||
.accentColor(.accentColor)
|
.accentColor(.accentColor)
|
||||||
.searchToolbarBehaviorIfAvailable()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Tab Content
|
// MARK: - Tab Content
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var moreTabContent: some View {
|
private var mainTabsContent: some View {
|
||||||
if searchViewModel.searchQuery.isEmpty {
|
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||||
moreTabsList
|
tabView(for: tab)
|
||||||
} else {
|
.tabItem {
|
||||||
searchResultsView
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
|
}
|
||||||
|
.tag(idx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var searchResultsView: some View {
|
private var moreTabContent: some View {
|
||||||
if searchViewModel.isLoading {
|
VStack(spacing: 0) {
|
||||||
ProgressView("Searching...")
|
moreTabsList
|
||||||
.padding()
|
moreTabsFooter
|
||||||
} else if let error = searchViewModel.errorMessage {
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.padding()
|
|
||||||
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
|
||||||
List(bookmarks) { bookmark in
|
|
||||||
// Hidden NavigationLink to remove disclosure indicator
|
|
||||||
// To restore: uncomment block below and remove ZStack
|
|
||||||
ZStack {
|
|
||||||
NavigationLink {
|
|
||||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
|
||||||
} label: {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
.opacity(0)
|
.tabItem {
|
||||||
|
Label("More", systemImage: "ellipsis")
|
||||||
BookmarkCardView(
|
|
||||||
bookmark: bookmark,
|
|
||||||
currentState: .all,
|
|
||||||
layout: cardLayoutStyle,
|
|
||||||
onArchive: { _ in },
|
|
||||||
onDelete: { _ in },
|
|
||||||
onToggleFavorite: { _ in }
|
|
||||||
)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
}
|
||||||
.listRowInsets(EdgeInsets(
|
.badge(offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0)
|
||||||
top: cardLayoutStyle == .compact ? 8 : 12,
|
.tag(mainTabs.count)
|
||||||
leading: 16,
|
.onAppear {
|
||||||
bottom: cardLayoutStyle == .compact ? 8 : 12,
|
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||||
trailing: 16
|
selectedMoreTab = nil
|
||||||
))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color(R.color.bookmark_list_bg))
|
|
||||||
.listStyle(.plain)
|
|
||||||
} else if searchViewModel.searchQuery.isEmpty == false {
|
|
||||||
ContentUnavailableView("No results", systemImage: "magnifyingglass", description: Text("No bookmarks found."))
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,6 +70,12 @@ struct PhoneTabView: View {
|
|||||||
tabView(for: tab)
|
tabView(for: tab)
|
||||||
.navigationTitle(tab.label)
|
.navigationTitle(tab.label)
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.onDisappear {
|
||||||
|
// tags and search handle navigation by own
|
||||||
|
if tab != .tags && tab != .search {
|
||||||
|
selectedMoreTab = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
}
|
}
|
||||||
@ -240,7 +121,7 @@ struct PhoneTabView: View {
|
|||||||
case .archived:
|
case .archived:
|
||||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
|
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
|
||||||
case .search:
|
case .search:
|
||||||
EmptyView() // search is directly implemented
|
SearchBookmarksView(selectedBookmark: .constant(nil))
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView()
|
SettingsView()
|
||||||
case .article:
|
case .article:
|
||||||
@ -254,28 +135,3 @@ struct PhoneTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - View Extension for iOS 26+ Compatibility
|
|
||||||
extension View {
|
|
||||||
@ViewBuilder
|
|
||||||
func searchToolbarBehaviorIfAvailable() -> some View {
|
|
||||||
if #available(iOS 26, *) {
|
|
||||||
self
|
|
||||||
.searchToolbarBehavior(.minimize)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func tabBarMinimizeBehaviorIfAvailable() -> some View {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
self
|
|
||||||
.tabBarMinimizeBehavior(.onScrollDown)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
// Created by Ilyas Hallak on 01.07.25.
|
// Created by Ilyas Hallak on 01.07.25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
||||||
case search, all, unread, favorite, archived, article, videos, pictures, tags, settings
|
case search, all, unread, favorite, archived, article, videos, pictures, tags, settings
|
||||||
|
|
||||||
@ -14,16 +12,16 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .all: return NSLocalizedString("All", comment: "")
|
case .all: return "All"
|
||||||
case .unread: return NSLocalizedString("Unread", comment: "")
|
case .unread: return "Unread"
|
||||||
case .favorite: return NSLocalizedString("Favorites", comment: "")
|
case .favorite: return "Favorites"
|
||||||
case .archived: return NSLocalizedString("Archive", comment: "")
|
case .archived: return "Archive"
|
||||||
case .search: return NSLocalizedString("Search", comment: "")
|
case .search: return "Search"
|
||||||
case .settings: return NSLocalizedString("Settings", comment: "")
|
case .settings: return "Settings"
|
||||||
case .article: return NSLocalizedString("Articles", comment: "")
|
case .article: return "Articles"
|
||||||
case .videos: return NSLocalizedString("Videos", comment: "")
|
case .videos: return "Videos"
|
||||||
case .pictures: return NSLocalizedString("Pictures", comment: "")
|
case .pictures: return "Pictures"
|
||||||
case .tags: return NSLocalizedString("Tags", comment: "")
|
case .tags: return "Tags"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ struct MainTabView: View {
|
|||||||
@State private var selectedTab: SidebarTab = .unread
|
@State private var selectedTab: SidebarTab = .unread
|
||||||
@State var selectedBookmark: Bookmark?
|
@State var selectedBookmark: Bookmark?
|
||||||
@StateObject private var playerUIState = PlayerUIState()
|
@StateObject private var playerUIState = PlayerUIState()
|
||||||
@State private var showReleaseNotes = false
|
|
||||||
|
|
||||||
// sizeClass
|
// sizeClass
|
||||||
@Environment(\.horizontalSizeClass)
|
@Environment(\.horizontalSizeClass)
|
||||||
@ -15,7 +14,6 @@ struct MainTabView: View {
|
|||||||
var verticalSizeClass
|
var verticalSizeClass
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
|
||||||
if UIDevice.isPhone {
|
if UIDevice.isPhone {
|
||||||
PhoneTabView()
|
PhoneTabView()
|
||||||
.environmentObject(playerUIState)
|
.environmentObject(playerUIState)
|
||||||
@ -24,20 +22,6 @@ struct MainTabView: View {
|
|||||||
.environmentObject(playerUIState)
|
.environmentObject(playerUIState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showReleaseNotes) {
|
|
||||||
ReleaseNotesView()
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
checkForNewVersion()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkForNewVersion() {
|
|
||||||
if VersionManager.shared.isNewVersion {
|
|
||||||
showReleaseNotes = true
|
|
||||||
VersionManager.shared.markVersionAsSeen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@ -27,10 +27,6 @@ class AppSettings: ObservableObject {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ struct AppearanceSettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
SectionHeader(title: "Appearance".localized, icon: "paintbrush")
|
SectionHeader(title: "Appearance", icon: "paintbrush")
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
// Theme Section
|
// Theme Section
|
||||||
|
|||||||
@ -9,7 +9,7 @@ struct CacheSettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
|
SectionHeader(title: "Cache Settings", icon: "internaldrive")
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ struct FontSettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
SectionHeader(title: "Font Settings".localized, icon: "textformat")
|
SectionHeader(title: "Font Settings", icon: "textformat")
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
// Font Family Picker
|
// Font Family Picker
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct LegalNoticeView: View {
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
|
||||||
Text("Legal Notice")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
sectionView(
|
|
||||||
title: "App Publisher",
|
|
||||||
content: """
|
|
||||||
Ilyas Hallak
|
|
||||||
Albert-Bischof-Str. 18
|
|
||||||
28357 Bremen
|
|
||||||
Germany
|
|
||||||
|
|
||||||
Email: hi@ilyashallak.de
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "Content Responsibility",
|
|
||||||
content: "The publisher is responsible for the content of this application in accordance with applicable laws."
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "App Information",
|
|
||||||
content: """
|
|
||||||
readeck iOS - Bookmark Management Client
|
|
||||||
Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown")
|
|
||||||
Build: \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown")
|
|
||||||
|
|
||||||
This app is an open source client for readeck bookmark management.
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "License",
|
|
||||||
content: "This software is released under the MIT License. The source code is available at the official repository."
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "Disclaimer",
|
|
||||||
content: "The app is provided \"as is\" without warranty of any kind. The publisher assumes no liability for damages arising from the use of this application."
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: Add business registration details if needed
|
|
||||||
// sectionView(
|
|
||||||
// title: "Business Registration",
|
|
||||||
// content: """
|
|
||||||
// [Business Registration Number]
|
|
||||||
// [Tax ID / VAT Number]
|
|
||||||
// [Responsible Authority]
|
|
||||||
// """
|
|
||||||
// )
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.navigationTitle("")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Done") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func sectionView(title: String, content: String) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(title)
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
|
|
||||||
Text(content)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
LegalNoticeView()
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct LegalPrivacySettingsView: View {
|
|
||||||
@State private var showingPrivacyPolicy = false
|
|
||||||
@State private var showingLegalNotice = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
// Privacy Policy
|
|
||||||
Button(action: {
|
|
||||||
showingPrivacyPolicy = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Privacy Policy", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
// Legal Notice
|
|
||||||
Button(action: {
|
|
||||||
showingLegalNotice = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Legal Notice", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
// Support Section
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
// Report an Issue
|
|
||||||
Button(action: {
|
|
||||||
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Report an Issue", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
// Contact Support
|
|
||||||
Button(action: {
|
|
||||||
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Contact Support", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingPrivacyPolicy) {
|
|
||||||
PrivacyPolicyView()
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingLegalNotice) {
|
|
||||||
LegalNoticeView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
LegalPrivacySettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct PrivacyPolicyView: View {
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
|
||||||
Text("Privacy Policy")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
|
|
||||||
Text("Last updated: September 20, 2025")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
sectionView(
|
|
||||||
title: "Data Collection",
|
|
||||||
content: "readeck iOS does not collect, store, or transmit any personal data. The app operates as a client for your personal readeck server and all data remains on your device or your own server infrastructure."
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "Local Storage",
|
|
||||||
content: "The app stores bookmarks locally on your device using CoreData for offline access. Login credentials are securely stored in the iOS Keychain. No data is shared with third parties."
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "Server Communication",
|
|
||||||
content: "The app communicates only with your configured readeck server to synchronize bookmarks. No analytics, tracking, or telemetry data is collected or transmitted."
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "Third-Party Services",
|
|
||||||
content: "This app does not use any third-party analytics, advertising, or tracking services. It does not integrate with social media platforms or other external services."
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "Your Rights",
|
|
||||||
content: "Since no personal data is collected, processed, or stored by us, there is no personal data to access, modify, or delete from our side. All your data is under your control on your device and server."
|
|
||||||
)
|
|
||||||
|
|
||||||
sectionView(
|
|
||||||
title: "Contact",
|
|
||||||
content: "If you have questions about this privacy policy, please contact us at: hi@ilyashallak.de"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.navigationTitle("")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Done") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func sectionView(title: String, content: String) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(title)
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
|
|
||||||
Text(content)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
PrivacyPolicyView()
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension AttributedString {
|
|
||||||
init(styledMarkdown markdownString: String) throws {
|
|
||||||
var output = try AttributedString(
|
|
||||||
markdown: markdownString,
|
|
||||||
options: .init(
|
|
||||||
allowsExtendedAttributes: true,
|
|
||||||
interpretedSyntax: .full,
|
|
||||||
failurePolicy: .returnPartiallyParsedIfPossible
|
|
||||||
),
|
|
||||||
baseURL: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
|
|
||||||
guard let intentBlock = intentBlock else { continue }
|
|
||||||
for intent in intentBlock.components {
|
|
||||||
switch intent.kind {
|
|
||||||
case .header(level: let level):
|
|
||||||
switch level {
|
|
||||||
case 1:
|
|
||||||
output[intentRange].font = .system(.title).bold()
|
|
||||||
case 2:
|
|
||||||
output[intentRange].font = .system(.title2).bold()
|
|
||||||
case 3:
|
|
||||||
output[intentRange].font = .system(.title3).bold()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if intentRange.lowerBound != output.startIndex {
|
|
||||||
output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self = output
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReleaseNotesView: View {
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
if let attributedString = loadReleaseNotes() {
|
|
||||||
Text(attributedString)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
Text("Unable to load release notes")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("What's New")
|
|
||||||
.navigationBarTitleDisplayMode(.large)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Done") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadReleaseNotes() -> AttributedString? {
|
|
||||||
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
|
||||||
let markdownContent = try? String(contentsOf: url),
|
|
||||||
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return attributedString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ReleaseNotesView()
|
|
||||||
}
|
|
||||||
@ -33,9 +33,6 @@ struct SettingsContainerView: View {
|
|||||||
SettingsServerView()
|
SettingsServerView()
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
|
|
||||||
LegalPrivacySettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
|
|
||||||
// Debug-only Logging Configuration
|
// Debug-only Logging Configuration
|
||||||
if Bundle.main.isDebugBuild {
|
if Bundle.main.isDebugBuild {
|
||||||
debugSettingsSection
|
debugSettingsSection
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsGeneralView: View {
|
struct SettingsGeneralView: View {
|
||||||
@State private var viewModel: SettingsGeneralViewModel
|
@State private var viewModel: SettingsGeneralViewModel
|
||||||
@State private var showReleaseNotes = false
|
|
||||||
|
|
||||||
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
|
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
@ -17,30 +16,12 @@ struct SettingsGeneralView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
SectionHeader(title: "General Settings".localized, icon: "gear")
|
SectionHeader(title: "General Settings", icon: "gear")
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("General")
|
Text("General")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
// What's New Button
|
|
||||||
Button(action: {
|
|
||||||
showReleaseNotes = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Label("What's New", systemImage: "sparkles")
|
|
||||||
Spacer()
|
|
||||||
Text("Version \(VersionManager.shared.currentVersion)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
.onChange(of: viewModel.enableTTS) {
|
.onChange(of: viewModel.enableTTS) {
|
||||||
@ -52,23 +33,6 @@ 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) {
|
||||||
@ -91,6 +55,8 @@ 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())
|
||||||
}
|
}
|
||||||
@ -117,9 +83,6 @@ struct SettingsGeneralView: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showReleaseNotes) {
|
|
||||||
ReleaseNotesView()
|
|
||||||
}
|
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadGeneralSettings()
|
await viewModel.loadGeneralSettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,7 +36,6 @@ 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 {
|
||||||
@ -49,7 +48,6 @@ 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"
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ struct SettingsServerView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
|
SectionHeader(title: viewModel.isSetupMode ? "Server Settings" : "Server Connection", icon: "server.rack")
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
Text(viewModel.isSetupMode ?
|
Text(viewModel.isSetupMode ?
|
||||||
|
|||||||
@ -1,25 +1,8 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
|
||||||
struct URLUtil {
|
class SafariUtil {
|
||||||
|
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,
|
||||||
@ -39,7 +22,9 @@ struct URLUtil {
|
|||||||
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: "")
|
||||||
@ -51,58 +51,3 @@ struct readeckApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct TestView: View {
|
|
||||||
var body: some View {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
Text("hello")
|
|
||||||
.toolbar {
|
|
||||||
ToolbarSpacer(.flexible)
|
|
||||||
|
|
||||||
ToolbarItem {
|
|
||||||
Button {
|
|
||||||
|
|
||||||
} label: {
|
|
||||||
Label("Favorite", systemImage: "share")
|
|
||||||
.symbolVariant(.none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolbarSpacer(.fixed)
|
|
||||||
|
|
||||||
ToolbarItemGroup {
|
|
||||||
Button {
|
|
||||||
|
|
||||||
} label: {
|
|
||||||
Label("Favorite", systemImage: "heart")
|
|
||||||
.symbolVariant(.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Info", systemImage: "info") {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .bottomBar) {
|
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
|
|
||||||
} label: {
|
|
||||||
Label("Favorite", systemImage: "heart")
|
|
||||||
.symbolVariant(.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Info", systemImage: "info") {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
.toolbar(removing: .title)
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
} else {
|
|
||||||
Text("hello1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
class VersionManager {
|
|
||||||
static let shared = VersionManager()
|
|
||||||
|
|
||||||
private let lastSeenVersionKey = "lastSeenAppVersion"
|
|
||||||
private let userDefaults = UserDefaults.standard
|
|
||||||
|
|
||||||
var currentVersion: String {
|
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentBuild: String {
|
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullVersion: String {
|
|
||||||
"\(currentVersion) (\(currentBuild))"
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastSeenVersion: String? {
|
|
||||||
userDefaults.string(forKey: lastSeenVersionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNewVersion: Bool {
|
|
||||||
guard let lastSeen = lastSeenVersion else {
|
|
||||||
// First launch
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return lastSeen != currentVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
func markVersionAsSeen() {
|
|
||||||
userDefaults.set(currentVersion, forKey: lastSeenVersionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
}
|
|
||||||
@ -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="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">
|
<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,7 +57,6 @@
|
|||||||
<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"/>
|
||||||
|
|||||||
|
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 |