Compare commits

..

1 Commits

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

27
CHANGELOG.md Normal file
View File

@ -0,0 +1,27 @@
# Changelog
All changes to this project will be documented in this file.
## Planned for Version 1.0.0
**Initial release:**
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
- Share Extension for adding URLs from Safari and other apps
- Swipe actions for quick bookmark management
- Native iOS design with Dark Mode support
- Full iPad Support with Multi-Column Split View
- Font Customization
- Article View with Reading Time and Word Count
- Search functionality
- Support for tags
- Support for reading progress
- Save bookmarks when server is unavailable and sync when reconnected
## Planned for Version 1.1.0
- [ ] Add support for bookmark filtering and sorting options
- [ ] Add support for collection management
- [ ] Add support for custom themes
- [ ] Text highlighting of selected text in a article
- [ ] Multiple selection of bookmarks for bulk actions

View File

@ -7,49 +7,39 @@ 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
## Screenshots
<p align="center">
<img src="screenshots/main.webp" height="400" alt="Main View">
<img src="screenshots/detail.webp" height="400" alt="Detail View">
<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>
## Download ## Download
### App Store (Stable Releases) ### App Store (Stable Releases)
<a href="https://apps.apple.com/de/app/readeck/id6748764703"> The official app is available on the App Store with stable, tested releases:
<img src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" alt="Download on the App Store" width="200">
</a> [Download Readeck on the App Store](https://apps.apple.com/de/app/readeck/id6748764703)
### TestFlight Beta Access (Early Releases) ### TestFlight Beta Access (Early Releases)
For early access to new features and beta versions (use with caution):
[Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR) [Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR)
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.
- For details and recent changes, please refer to the release notes in TestFlight or the [Release Notes](./readeck/UI/Resources/RELEASE_NOTES.md). - For details and recent changes, please refer to the release notes in TestFlight or the [Changelog](./CHANGELOG.md).
Please report any bugs, crashes, or suggestions directly through TestFlight, or email me at ilhallak@gmail.com. Thank you for helping make Readeck better! Please report any bugs, crashes, or suggestions directly through TestFlight, or email me at ilhallak@gmail.com. Thank you for helping make Readeck better!
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)
@ -84,7 +74,7 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
## Versions ## Versions
[see Release Notes](./readeck/UI/Resources/RELEASE_NOTES.md) [see Changelog](./CHANGELOG.md)
## Contributing ## Contributing

View File

@ -49,16 +49,14 @@ class OfflineBookmarkManager: @unchecked Sendable {
} }
} }
func getTags() async -> [String] { func getTags() -> [String] {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do { do {
return try await backgroundContext.perform { return try context.safePerform { [weak self] in
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest() guard let self = self else { return [] }
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
let tagEntities = try backgroundContext.fetch(fetchRequest) let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
return tagEntities.compactMap { $0.name } let tagEntities = try self.context.fetch(fetchRequest)
return tagEntities.compactMap { $0.name }.sorted()
} }
} catch { } catch {
print("Failed to fetch tags: \(error)") print("Failed to fetch tags: \(error)")
@ -66,85 +64,4 @@ class OfflineBookmarkManager: @unchecked Sendable {
} }
} }
func saveTags(_ tags: [String]) async {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
try await backgroundContext.perform {
// Batch fetch existing tags
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
let existingNames = Set(existingEntities.compactMap { $0.name })
// Only insert new tags
var insertCount = 0
for tag in tags {
if !existingNames.contains(tag) {
let entity = TagEntity(context: backgroundContext)
entity.name = tag
entity.count = 0
insertCount += 1
}
}
// Only save if there are new tags
if insertCount > 0 {
try backgroundContext.save()
print("Saved \(insertCount) new tags to Core Data")
}
}
} catch {
print("Failed to save tags: \(error)")
}
}
func saveTagsWithCount(_ tags: [BookmarkLabelDto]) async {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
try await backgroundContext.perform {
// Batch fetch existing tags
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
var existingByName: [String: TagEntity] = [:]
for entity in existingEntities {
if let name = entity.name {
existingByName[name] = entity
}
}
// Insert or update tags
var insertCount = 0
var updateCount = 0
for tag in tags {
if let existing = existingByName[tag.name] {
// Update count if changed
if existing.count != tag.count {
existing.count = Int32(tag.count)
updateCount += 1
}
} else {
// Insert new tag
let entity = TagEntity(context: backgroundContext)
entity.name = tag.name
entity.count = Int32(tag.count)
insertCount += 1
}
}
// Only save if there are changes
if insertCount > 0 || updateCount > 0 {
try backgroundContext.save()
print("Saved \(insertCount) new tags and updated \(updateCount) tags to Core Data")
}
}
} catch {
print("Failed to save tags with count: \(error)")
}
}
} }

View File

@ -0,0 +1,62 @@
import Foundation
import Network
class ServerConnectivity: ObservableObject {
@Published var isServerReachable = false
static let shared = ServerConnectivity()
private init() {}
// Check if the Readeck server endpoint is reachable
static func isServerReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint + "/api/health") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 5.0 // 5 second timeout
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
print("Server connectivity check failed: \(error)")
}
return false
}
// Alternative check using ping-style endpoint
static func isServerReachableSync() -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint) else {
return false
}
let semaphore = DispatchSemaphore(value: 0)
var isReachable = false
var request = URLRequest(url: url)
request.httpMethod = "HEAD" // Just check if server responds
request.timeoutInterval = 3.0
let task = URLSession.shared.dataTask(with: request) { _, response, error in
if let httpResponse = response as? HTTPURLResponse {
isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error
}
semaphore.signal()
}
task.resume()
_ = semaphore.wait(timeout: .now() + 3.0)
return isReachable
}
}

View File

@ -1,13 +1,10 @@
import SwiftUI import SwiftUI
import CoreData
struct ShareBookmarkView: View { struct ShareBookmarkView: View {
@ObservedObject var viewModel: ShareBookmarkViewModel @ObservedObject var viewModel: ShareBookmarkViewModel
@State private var keyboardHeight: CGFloat = 0 @State private var keyboardHeight: CGFloat = 0
@FocusState private var focusedField: AddBookmarkFieldFocus? @FocusState private var focusedField: AddBookmarkFieldFocus?
@Environment(\.managedObjectContext) private var viewContext
private func dismissKeyboard() { private func dismissKeyboard() {
NotificationCenter.default.post(name: .dismissKeyboard, object: nil) NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
} }
@ -42,6 +39,7 @@ struct ShareBookmarkView: View {
saveButtonSection saveButtonSection
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.onAppear { viewModel.onAppear() }
.ignoresSafeArea(.keyboard, edges: .bottom) .ignoresSafeArea(.keyboard, edges: .bottom)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
@ -136,14 +134,14 @@ struct ShareBookmarkView: View {
@ViewBuilder @ViewBuilder
private var tagManagementSection: some View { private var tagManagementSection: some View {
CoreDataTagManagementView( if !viewModel.labels.isEmpty || !viewModel.isServerReachable {
TagManagementView(
allLabels: convertToBookmarkLabels(viewModel.labels),
selectedLabels: viewModel.selectedLabels, selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText, searchText: $viewModel.searchText,
isLabelsLoading: false,
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
searchFieldFocus: $focusedField, searchFieldFocus: $focusedField,
fetchLimit: 150,
sortOrder: viewModel.tagSortOrder,
availableLabelsTitle: "Most used labels",
context: viewContext,
onAddCustomTag: { onAddCustomTag: {
addCustomTag() addCustomTag()
}, },
@ -162,6 +160,7 @@ struct ShareBookmarkView: View {
.padding(.top, 20) .padding(.top, 20)
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
}
@ViewBuilder @ViewBuilder
private var statusSection: some View { private var statusSection: some View {
@ -200,7 +199,24 @@ struct ShareBookmarkView: View {
// MARK: - Helper Functions // MARK: - Helper Functions
private func convertToBookmarkLabels(_ dtos: [BookmarkLabelDto]) -> [BookmarkLabel] {
return dtos.map { .init(name: $0.name, count: $0.count, href: $0.href) }
}
private func convertToBookmarkLabelPages(_ dtoPages: [[BookmarkLabelDto]]) -> [[BookmarkLabel]] {
return dtoPages.map { convertToBookmarkLabels($0) }
}
private func addCustomTag() { private func addCustomTag() {
viewModel.addCustomTag(context: viewContext) let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText)
let availableLabels = viewModel.labels.map { $0.name }
let currentLabels = Array(viewModel.selectedLabels)
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
for label in uniqueLabels {
viewModel.selectedLabels.insert(label)
}
viewModel.searchText = ""
} }
} }

View File

@ -6,17 +6,41 @@ import CoreData
class ShareBookmarkViewModel: ObservableObject { class ShareBookmarkViewModel: ObservableObject {
@Published var url: String? @Published var url: String?
@Published var title: String = "" @Published var title: String = ""
@Published var labels: [BookmarkLabelDto] = []
@Published var selectedLabels: Set<String> = [] @Published var selectedLabels: Set<String> = []
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil @Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
@Published var isSaving: Bool = false @Published var isSaving: Bool = false
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var isServerReachable: Bool = true @Published var isServerReachable: Bool = true
let tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount
let extensionContext: NSExtensionContext? let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel private let logger = Logger.viewModel
private let serverCheck = ShareExtensionServerCheck.shared
private let tagRepository = TagRepository() var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) }
}
// filtered labels based on search text
var filteredLabels: [BookmarkLabelDto] {
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var availableLabelPages: [[BookmarkLabelDto]] {
let pageSize = 12 // Extension can't access Constants.Labels.pageSize
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
if labelsToShow.count <= pageSize {
return [labelsToShow]
} else {
return stride(from: 0, to: labelsToShow.count, by: pageSize).map {
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
}
}
}
init(extensionContext: NSExtensionContext?) { init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext self.extensionContext = extensionContext
@ -24,6 +48,19 @@ class ShareBookmarkViewModel: ObservableObject {
extractSharedContent() extractSharedContent()
} }
func onAppear() {
logger.debug("ShareBookmarkViewModel appeared")
checkServerReachability()
loadLabels()
}
private func checkServerReachability() {
let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger)
isServerReachable = ServerConnectivity.isServerReachableSync()
logger.info("Server reachability checked: \(isServerReachable)")
measurement.end()
}
private func extractSharedContent() { private func extractSharedContent() {
logger.debug("Starting to extract shared content") logger.debug("Starting to extract shared content")
guard let extensionContext = extensionContext else { guard let extensionContext = extensionContext else {
@ -90,6 +127,38 @@ class ShareBookmarkViewModel: ObservableObject {
} }
} }
func loadLabels() {
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
logger.debug("Starting to load labels")
Task {
let serverReachable = ServerConnectivity.isServerReachableSync()
logger.debug("Server reachable for labels: \(serverReachable)")
if serverReachable {
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
self?.statusMessage = (message, error, error ? "" : "")
} ?? []
let sorted = loaded.sorted { $0.count > $1.count }
await MainActor.run {
self.labels = Array(sorted)
self.logger.info("Loaded \(loaded.count) labels from API")
measurement.end()
}
} else {
let localTags = OfflineBookmarkManager.shared.getTags()
let localLabels = localTags.enumerated().map { index, tagName in
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
await MainActor.run {
self.labels = localLabels
self.logger.info("Loaded \(localLabels.count) labels from local database")
measurement.end()
}
}
}
}
func save() { func save() {
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)") logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
guard let url = url, !url.isEmpty else { guard let url = url, !url.isEmpty else {
@ -101,12 +170,12 @@ class ShareBookmarkViewModel: ObservableObject {
logger.debug("Set saving state to true") logger.debug("Set saving state to true")
// Check server connectivity // Check server connectivity
Task { let serverReachable = ServerConnectivity.isServerReachableSync()
let serverReachable = await serverCheck.checkServerReachability()
logger.debug("Server connectivity for save: \(serverReachable)") logger.debug("Server connectivity for save: \(serverReachable)")
if serverReachable { if serverReachable {
// Online - try to save via API // Online - try to save via API
logger.info("Attempting to save bookmark via API") logger.info("Attempting to save bookmark via API")
Task {
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
self?.logger.info("API save completed - Success: \(!error), Message: \(message)") self?.logger.info("API save completed - Success: \(!error), Message: \(message)")
self?.statusMessage = (message, error, error ? "" : "") self?.statusMessage = (message, error, error ? "" : "")
@ -120,6 +189,7 @@ class ShareBookmarkViewModel: ObservableObject {
self?.logger.error("Failed to save bookmark via API: \(message)") self?.logger.error("Failed to save bookmark via API: \(message)")
} }
} }
}
} else { } else {
// Server not reachable - save locally // Server not reachable - save locally
logger.info("Server not reachable, attempting local save") logger.info("Server not reachable, attempting local save")
@ -130,49 +200,21 @@ class ShareBookmarkViewModel: ObservableObject {
) )
logger.info("Local save result: \(success)") logger.info("Local save result: \(success)")
await MainActor.run { DispatchQueue.main.async {
self.isSaving = false self.isSaving = false
if success { if success {
self.logger.info("Bookmark saved locally successfully") self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.completeExtensionRequest()
}
} else { } else {
self.logger.error("Failed to save bookmark locally") self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "") self.statusMessage = ("Failed to save locally.", true, "")
} }
} }
if success {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await MainActor.run {
self.completeExtensionRequest()
} }
} }
}
}
}
func addCustomTag(context: NSManagedObjectContext) {
let splitLabels = LabelUtils.splitLabelsFromInput(searchText)
// Fetch available labels from Core Data
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
let availableLabels = (try? context.fetch(fetchRequest))?.compactMap { $0.name } ?? []
let currentLabels = Array(selectedLabels)
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
for label in uniqueLabels {
selectedLabels.insert(label)
// Save new label to Core Data so it's available next time
tagRepository.saveNewLabel(name: label, context: context)
}
// Force refresh of @FetchRequest in CoreDataTagManagementView
// This ensures newly created labels appear immediately in the search results
context.refreshAllObjects()
searchText = ""
}
private func completeExtensionRequest() { private func completeExtensionRequest() {
logger.debug("Completing extension request") logger.debug("Completing extension request")

View File

@ -1,41 +0,0 @@
import Foundation
/// Simple server check manager for Share Extension with caching
class ShareExtensionServerCheck {
static let shared = ShareExtensionServerCheck()
// Cache properties
private var cachedResult: Bool?
private var lastCheckTime: Date?
private let cacheTTL: TimeInterval = 30.0
private init() {}
func checkServerReachability() async -> Bool {
// Check cache first
if let cached = getCachedResult() {
return cached
}
// Use SimpleAPI for actual check
let result = await SimpleAPI.checkServerReachability()
updateCache(result: result)
return result
}
// MARK: - Cache Management
private func getCachedResult() -> Bool? {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedResult else {
return nil
}
return cached
}
private func updateCache(result: Bool) {
cachedResult = result
lastCheckTime = Date()
}
}

View File

@ -12,14 +12,13 @@ import SwiftUI
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
private var hostingController: UIHostingController<AnyView>? private var hostingController: UIHostingController<ShareBookmarkView>?
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext) let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext)
let swiftUIView = ShareBookmarkView(viewModel: viewModel) let swiftUIView = ShareBookmarkView(viewModel: viewModel)
.environment(\.managedObjectContext, CoreDataManager.shared.context) let hostingController = UIHostingController(rootView: swiftUIView)
let hostingController = UIHostingController(rootView: AnyView(swiftUIView))
addChild(hostingController) addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view) view.addSubview(hostingController.view)

View File

@ -3,39 +3,6 @@ import Foundation
class SimpleAPI { class SimpleAPI {
private static let logger = Logger.network private static let logger = Logger.network
// MARK: - Server Info
static func checkServerReachability() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: "\(endpoint)/api/info") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "accept")
request.timeoutInterval = 5.0
if let token = KeychainHelper.shared.loadToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
}
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode {
logger.info("Server is reachable")
return true
}
} catch {
logger.error("Server reachability check failed: \(error.localizedDescription)")
return false
}
return false
}
// MARK: - API Methods // MARK: - API Methods
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async { static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
logger.info("Adding bookmark: \(url)") logger.info("Adding bookmark: \(url)")

View File

@ -1,17 +1,5 @@
import Foundation import Foundation
public struct ServerInfoDto: Codable {
public let version: String
public let buildDate: String?
public let userAgent: String?
public enum CodingKeys: String, CodingKey {
case version
case buildDate = "build_date"
case userAgent = "user_agent"
}
}
public struct CreateBookmarkRequestDto: Codable { public struct CreateBookmarkRequestDto: Codable {
public let labels: [String]? public let labels: [String]?
public let title: String? public let title: String?
@ -45,3 +33,4 @@ public struct BookmarkLabelDto: Codable, Identifiable {
self.href = href self.href = href
} }
} }

View File

@ -1,63 +0,0 @@
import Foundation
import CoreData
/// Simple repository for managing tags in Share Extension
class TagRepository {
private let logger = Logger.data
/// Saves a new label to Core Data if it doesn't already exist
/// - Parameters:
/// - name: The label name to save
/// - context: The managed object context to use
func saveNewLabel(name: String, context: NSManagedObjectContext) {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// Perform save in a synchronous block to ensure it completes before extension closes
context.performAndWait {
// Check if label already exists
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
fetchRequest.fetchLimit = 1
do {
let existingTags = try context.fetch(fetchRequest)
// Only create if it doesn't exist
if existingTags.isEmpty {
let newTag = TagEntity(context: context)
newTag.name = trimmedName
newTag.count = 1 // New label is being used immediately
try context.save()
logger.info("Successfully saved new label '\(trimmedName)' to Core Data")
// Force immediate persistence to disk for share extension
// Based on: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
// 1. Process pending changes
context.processPendingChanges()
// 2. Ensure persistent store coordinator writes to disk
// This is critical for extensions as they may be terminated quickly
if context.persistentStoreCoordinator != nil {
// Refresh all objects to ensure changes are pushed to store
context.refreshAllObjects()
// Reset staleness interval temporarily to force immediate persistence
let originalStalenessInterval = context.stalenessInterval
context.stalenessInterval = 0
context.refreshAllObjects()
context.stalenessInterval = originalStalenessInterval
logger.debug("Forced context refresh to ensure persistence")
}
} else {
logger.debug("Label '\(trimmedName)' already exists, skipping creation")
}
} catch {
logger.error("Failed to save new label '\(trimmedName)': \(error.localizedDescription)")
}
}
}
}

View File

@ -1,283 +0,0 @@
# Code Review - Tag Management Refactoring
**Commit**: ec5706c - Refactor tag management to use Core Data with configurable sorting
**Date**: 2025-11-08
**Files Changed**: 31 files (+747, -264)
## Overview
This review covers a comprehensive refactoring of the tag management system, migrating from API-based tag loading to a Core Data-first approach with background synchronization.
---
## ✅ Strengths
### Architecture & Design
1. **Clean Architecture Compliance**
- New `SyncTagsUseCase` properly separates concerns
- ViewModels now only interact with UseCases, not Repositories
- Proper dependency injection through UseCaseFactory
2. **Performance Improvements**
- Cache-first strategy provides instant UI response
- Background sync eliminates UI blocking
- Reduced server load through local caching
- SwiftUI `@FetchRequest` provides automatic reactive updates
3. **Offline Support**
- Tags work completely offline using Core Data
- Share Extension uses cached tags (no network required)
- Graceful degradation when server is unreachable
4. **User Experience**
- Configurable sorting (by count/alphabetically)
- Clear sorting indicators in UI
- Proper localization (EN/DE)
- "Most used tags" in Share Extension for quick access
### Code Quality
1. **Consistency**
- Consistent use of `@MainActor` for UI updates
- Proper async/await patterns throughout
- Clear naming conventions
2. **Documentation**
- Comprehensive commit message
- Inline documentation for complex logic
- `Tags-Sync.md` documentation created
3. **Testing Support**
- Mock implementations added for all new UseCases
- Testable architecture with clear boundaries
---
## ⚠️ Issues & Concerns
### Critical
None identified.
### Major
1. **LabelsRepository Duplication** (Priority: HIGH)
- `LabelsRepository` is instantiated multiple times in different factories
- Not using lazy singleton pattern
- Could lead to multiple concurrent API calls
**Location**:
- `DefaultUseCaseFactory.makeGetLabelsUseCase()` - line 101
- `DefaultUseCaseFactory.makeSyncTagsUseCase()` - line 107
**Impact**: Inefficient, potential race conditions
2. **Missing Error Handling** (Priority: MEDIUM)
- `syncTags()` silently swallows all errors with `try?`
- No user feedback if sync fails
- No retry mechanism
**Locations**:
- `AddBookmarkViewModel.syncTags()` - line 69
- `BookmarkLabelsViewModel.syncTags()` - line 45
3. **Legacy Code Not Fully Removed** (Priority: LOW)
- `AddBookmarkViewModel.loadAllLabels()` still exists but unused
- `BookmarkLabelsViewModel.allLabels` property unused
- `LegacyTagManagementView` marked deprecated but not removed
**Impact**: Code bloat, confusion for future developers
### Minor
1. **Hardcoded Values**
- Share Extension: `fetchLimit: 150` hardcoded in view
- Should be a constant
**Location**: `ShareBookmarkView.swift:143`
2. **Inconsistent Localization Approach**
- Share Extension uses `"Most used tags"` directly in code
- Should use `.localized` extension like main app
**Location**: `ShareBookmarkView.swift:145`
3. **Missing Documentation**
- `CoreDataTagManagementView` has no class-level documentation
- Complex `@FetchRequest` initialization not explained
**Location**: `CoreDataTagManagementView.swift:4`
4. **Code Duplication**
- Tag sync logic duplicated in `GetLabelsUseCase` and `SyncTagsUseCase`
- Both just call `labelsRepository.getLabels()`
**Locations**:
- `GetLabelsUseCase.execute()` - line 14
- `SyncTagsUseCase.execute()` - line 19
---
## 🔍 Specific File Reviews
### ShareBookmarkViewModel.swift
**Status**: ✅ Good
**Changes**: Removed 92 lines of label fetching logic
- ✅ Properly simplified by removing API logic
- ✅ Uses Core Data via `addCustomTag()` helper
- ✅ Clean separation of concerns
- ⚠️ Could add logging for Core Data fetch failures
### CoreDataTagManagementView.swift
**Status**: ✅ Good
**Changes**: New file, 255 lines
- ✅ Well-structured with clear sections
- ✅ Proper use of `@FetchRequest`
- ✅ Flexible with optional parameters
- ⚠️ Needs class/struct documentation
- ⚠️ `availableTagsTitle` parameter could be better named (`customSectionTitle`?)
### SyncTagsUseCase.swift
**Status**: ⚠️ Needs Improvement
**Changes**: New file, 21 lines
- ✅ Follows UseCase pattern correctly
- ✅ Good documentation comment
- ⚠️ Essentially duplicates `GetLabelsUseCase`
- 💡 Could be merged or one could wrap the other
### LabelsRepository.swift
**Status**: ✅ Excellent
**Changes**: Enhanced with batch updates and conflict detection
- ✅ Excellent cache-first + background sync implementation
- ✅ Proper batch operations
- ✅ Silent failure handling
- ✅ Efficient Core Data updates (only saves if changed)
### AddBookmarkView.swift
**Status**: ✅ Good
**Changes**: Migrated to CoreDataTagManagementView
- ✅ Clean migration from old TagManagementView
- ✅ Proper use of AppSettings for sort order
- ✅ Clear UI with sort indicator
- ⚠️ `.onAppear` and `.task` mixing removed - good!
### Settings Integration
**Status**: ✅ Excellent
**Changes**: New TagSortOrder setting with persistence
- ✅ Clean domain model separation
- ✅ Proper persistence in SettingsRepository
- ✅ Good integration with AppSettings
- ✅ UI properly reflects settings changes
---
## 📋 TODO List - Improvements
### High Priority
- [ ] **Refactor LabelsRepository instantiation**
- Create lazy singleton in DefaultUseCaseFactory
- Reuse same instance for GetLabelsUseCase and SyncTagsUseCase
- Add comment explaining why singleton is safe here
- [ ] **Add error handling to sync operations**
- Log errors instead of silently swallowing
- Consider adding retry logic with exponential backoff
- Optional: Show subtle indicator when sync fails
- [ ] **Remove unused legacy code**
- Delete `AddBookmarkViewModel.loadAllLabels()`
- Delete `BookmarkLabelsViewModel.allLabels` property
- Remove `LegacyTagManagementView.swift` entirely (currently just deprecated)
### Medium Priority
- [ ] **Extract constants**
- Create `Constants.Tags.maxShareExtensionTags = 150`
- Create `Constants.Tags.fetchBatchSize = 20`
- Reference in CoreDataTagManagementView and ShareBookmarkView
- [ ] **Improve localization consistency**
- Use `.localized` extension in ShareBookmarkView
- Ensure all user-facing strings are localized
- [ ] **Add documentation**
- Document `CoreDataTagManagementView` with usage examples
- Explain `@FetchRequest` initialization pattern
- Add example of how to use `availableTagsTitle` parameter
### Low Priority
- [ ] **Consolidate UseCases**
- Consider if `SyncTagsUseCase` is necessary
- Option 1: Make `GetLabelsUseCase` have a `syncOnly` parameter
- Option 2: Have `SyncTagsUseCase` wrap `GetLabelsUseCase`
- Document decision either way
- [ ] **Add unit tests**
- Test `SyncTagsUseCase` with mock repository
- Test `CoreDataTagManagementView` sort order changes
- Test tag sync triggers in ViewModels
- [ ] **Performance monitoring**
- Add metrics for tag sync duration
- Track cache hit rate
- Monitor Core Data batch operation performance
- [ ] **Improve parameter naming**
- Rename `availableTagsTitle` to `customSectionTitle` or `sectionHeaderTitle`
- More descriptive than "available tags"
---
## 🎯 Summary
### Overall Assessment: ✅ **EXCELLENT**
This refactoring successfully achieves its goals:
- ✅ Improved performance through caching
- ✅ Better offline support
- ✅ Cleaner architecture
- ✅ Enhanced user experience
### Risk Level: **LOW**
The changes are well-structured and follow established patterns. The main risks are:
1. Repository instantiation inefficiency (easily fixed)
2. Silent error handling (minor, can be improved later)
### Recommendation: **APPROVE with minor follow-ups**
The code is production-ready. The identified improvements are optimizations and cleanups that can be addressed in follow-up commits without blocking deployment.
---
## 📊 Metrics
- **Lines Added**: 747
- **Lines Removed**: 264
- **Net Change**: +483 lines
- **Files Modified**: 31
- **New Files**: 7
- **Deleted Files**: 0 (1 renamed)
- **Test Coverage**: Mocks added ✅
---
## 🏆 Best Practices Demonstrated
1. ✅ Clean Architecture principles
2. ✅ SOLID principles (especially Single Responsibility)
3. ✅ Proper async/await usage
4. ✅ SwiftUI best practices (@FetchRequest, @Published)
5. ✅ Comprehensive localization
6. ✅ Backwards compatibility (deprecated instead of deleted)
7. ✅ Documentation and commit hygiene
8. ✅ Testability through dependency injection

View File

@ -1,203 +0,0 @@
# Tags Synchronization
This document describes how tags (labels) are synchronized and updated throughout the readeck app.
## Overview
The app uses a **cache-first strategy** with background synchronization to ensure fast UI responses while keeping data up-to-date with the server.
## Architecture
### Components
1. **Core Data Storage** (`TagEntity`)
- Local persistent storage for all tags
- Fields: `name` (String), `count` (Int32)
- Used as the single source of truth for all UI components
2. **LabelsRepository**
- Manages tag synchronization between API and Core Data
- Implements cache-first loading strategy
3. **CoreDataTagManagementView**
- SwiftUI view component for tag management
- Uses `@FetchRequest` to directly query Core Data
- Automatically updates when Core Data changes
4. **LabelsView**
- Full-screen tag list view
- Accessible via "More" → "Tags" tab
- Triggers manual tag synchronization
## Synchronization Flow
### When Tags are Fetched
Tags are synchronized in the following scenarios:
#### 1. Opening the Tags Tab
**Trigger**: User navigates to "More" → "Tags"
**Location**: `LabelsView.swift:43-46`
```swift
.onAppear {
Task {
await viewModel.loadLabels()
}
}
```
**Process**:
1. Immediately loads tags from Core Data (instant response)
2. Starts background API call to fetch latest tags
3. Updates Core Data if API call succeeds
4. Silently fails if server is unreachable (keeps cached data)
#### 2. Background Sync Strategy
**Implementation**: `LabelsRepository.getLabels()`
The repository uses a two-phase approach:
**Phase 1: Instant Response**
```swift
let cachedLabels = try await loadLabelsFromCoreData()
```
- Returns immediately with cached data
- Ensures UI is never blocked
**Phase 2: Background Update**
```swift
Task.detached(priority: .background) {
let dtos = try await self.api.getBookmarkLabels()
try? await self.saveLabels(dtos)
}
```
- Runs asynchronously in background
- Updates Core Data with latest server data
- Silent failure - no error shown to user if sync fails
#### 3. Adding a New Bookmark
**Trigger**: User opens "Add Bookmark" sheet
**Location**: `AddBookmarkView.swift:61-66`
```swift
.onAppear {
viewModel.checkClipboard()
Task {
await viewModel.syncTags()
}
}
```
**Process**:
1. Triggers background sync when view appears
2. `CoreDataTagManagementView` shows cached tags immediately
3. View automatically updates via `@FetchRequest` when sync completes
#### 4. Editing Bookmark Labels
**Trigger**: User opens "Manage Labels" sheet from bookmark detail
**Location**: `BookmarkLabelsView.swift:49-53`
```swift
.onAppear {
Task {
await viewModel.syncTags()
}
}
```
**Process**:
1. Triggers background sync when view appears
2. `CoreDataTagManagementView` shows cached tags immediately
3. View automatically updates via `@FetchRequest` when sync completes
#### 5. Share Extension
Tags are **not** synced in the Share Extension:
- Uses cached tags from Core Data only
- No API calls to minimize extension launch time
- Relies on tags synced by main app
**Reason**: Share Extensions should be fast and lightweight. Tags are already synchronized by the main app when opening tags tab or managing bookmark labels.
### When Core Data Updates
Core Data tag updates trigger automatic UI refreshes in all views using `@FetchRequest`:
- `CoreDataTagManagementView`
- `LabelsView`
This happens when:
- Background sync completes successfully
- New tags are created via bookmark operations
- Tag counts change due to bookmark label modifications
## Tag Display Configuration
### Share Extension
- **Fixed sorting**: Always by usage count (`.byCount`)
- **Display limit**: Top 150 tags
- **Label**: "Most used tags"
- **Rationale**: Quick access to most frequently used tags for fast bookmark creation
### Main App
- **User-configurable sorting**: Either by usage count or alphabetically
- **Display limit**: All tags (no limit)
- **Setting location**: Settings → Appearance → Tag Sort Order
- **Labels**:
- "Sorted by usage count" (when `.byCount`)
- "Sorted alphabetically" (when `.alphabetically`)
## Data Persistence
### Core Data Updates
Tags in Core Data are updated through:
1. **Batch sync** (`LabelsRepository.saveLabels`)
- Compares existing tags with new data from server
- Updates counts for existing tags
- Inserts new tags
- Only saves if changes detected
2. **Efficiency optimizations**:
- Batch fetch of existing entities
- Dictionary-based lookups for fast comparison
- Conditional saves to minimize disk I/O
## Error Handling
### Network Failures
- **Behavior**: Silent failure
- **User Experience**: App continues to work with cached data
- **Rationale**: Tags are not critical for app functionality; offline access is prioritized
### Core Data Errors
- **Read errors**: UI shows empty state or cached data
- **Write errors**: Logged but do not block UI operations
## Implementation Notes
### Deprecated Components
- `LegacyTagManagementView.swift`: Old API-based tag management (marked for removal)
- `TagManagementView.swift`: Deleted, replaced by `CoreDataTagManagementView.swift`
### Key Differences: New vs Old Approach
**Old (LegacyTagManagementView)**:
- Fetched tags from API on every view appearance
- Slower initial load
- Required network connectivity
- More server load
**New (CoreDataTagManagementView)**:
- Uses Core Data with `@FetchRequest`
- Instant UI response
- Works offline
- Automatic UI updates via SwiftUI reactivity
- Reduced server load through background sync
## Future Considerations
1. **Offline tag creation**: Currently, new tags can be created offline but won't sync until server is reachable
2. **Tag deletion**: Not implemented in current version
3. **Tag renaming**: Not implemented in current version
4. **Conflict resolution**: Tags created offline with same name as server tags will merge on sync

View File

@ -9,7 +9,6 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; }; 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; };
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; }; 5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; }; 5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -87,25 +86,14 @@
Data/Utils/LabelUtils.swift, Data/Utils/LabelUtils.swift,
Domain/Model/Bookmark.swift, Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift, Domain/Model/BookmarkLabel.swift,
Domain/Model/CardLayoutStyle.swift, Logger.swift,
Domain/Model/FontFamily.swift,
Domain/Model/FontSize.swift,
Domain/Model/Settings.swift,
Domain/Model/TagSortOrder.swift,
Domain/Model/Theme.swift,
Domain/Model/UrlOpener.swift,
readeck.xcdatamodeld, readeck.xcdatamodeld,
Splash.storyboard, Splash.storyboard,
UI/Components/Constants.swift, UI/Components/Constants.swift,
UI/Components/CoreDataTagManagementView.swift,
UI/Components/CustomTextFieldStyle.swift, UI/Components/CustomTextFieldStyle.swift,
UI/Components/LegacyTagManagementView.swift, UI/Components/TagManagementView.swift,
UI/Components/UnifiedLabelChip.swift, UI/Components/UnifiedLabelChip.swift,
UI/Extension/FontSizeExtension.swift,
UI/Models/AppSettings.swift,
UI/Utils/NotificationNames.swift, UI/Utils/NotificationNames.swift,
Utils/Logger.swift,
Utils/LogStore.swift,
); );
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */; target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
}; };
@ -163,7 +151,6 @@
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */, 5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */, 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */, 5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -255,7 +242,6 @@
5D348CC22E0C9F4F00D0AF21 /* netfox */, 5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */, 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5D9D95482E623668009AF769 /* Kingfisher */, 5D9D95482E623668009AF769 /* Kingfisher */,
5D48E6012EB402F50043F90F /* MarkdownUI */,
); );
productName = readeck; productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */; productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -347,7 +333,6 @@
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */, 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */, 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */, 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */; productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -452,7 +437,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist; INFOPLIST_FILE = URLShare/Info.plist;
@ -465,7 +450,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2.0; 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;
@ -485,7 +470,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist; INFOPLIST_FILE = URLShare/Info.plist;
@ -498,7 +483,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2.0; 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;
@ -640,7 +625,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@ -663,7 +648,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.2.0; 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 = "";
@ -684,7 +669,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 36; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@ -707,7 +692,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.2.0; 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 = "";
@ -869,14 +854,6 @@
minimumVersion = 1.21.0; minimumVersion = 1.21.0;
}; };
}; };
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.4.1;
};
};
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git"; repositoryURL = "https://github.com/onevcat/Kingfisher.git";
@ -901,11 +878,6 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */; package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = netfox; productName = netfox;
}; };
5D48E6012EB402F50043F90F /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
5D9D95482E623668009AF769 /* Kingfisher */ = { 5D9D95482E623668009AF769 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */; package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635", "originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457",
"pins" : [ "pins" : [
{ {
"identity" : "kingfisher", "identity" : "kingfisher",
@ -19,15 +19,6 @@
"version" : "1.21.0" "version" : "1.21.0"
} }
}, },
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{ {
"identity" : "r.swift", "identity" : "r.swift",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@ -46,24 +37,6 @@
"version" : "1.6.1" "version" : "1.6.1"
} }
}, },
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
},
{ {
"identity" : "xcodeedit", "identity" : "xcodeedit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@ -18,9 +18,6 @@ protocol PAPI {
func deleteBookmark(id: String) async throws func deleteBookmark(id: String) async throws
func searchBookmarks(search: String) async throws -> BookmarksPageDto func searchBookmarks(search: String) async throws -> BookmarksPageDto
func getBookmarkLabels() async throws -> [BookmarkLabelDto] func getBookmarkLabels() async throws -> [BookmarkLabelDto]
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
} }
class API: PAPI { class API: PAPI {
@ -244,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 {
@ -447,84 +442,6 @@ class API: PAPI {
logger.info("Successfully fetched \(result.count) bookmark labels") logger.info("Successfully fetched \(result.count) bookmark labels")
return result return result
} }
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] {
logger.debug("Fetching annotations for bookmark: \(bookmarkId)")
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let result = try await makeJSONRequest(
endpoint: endpoint,
responseType: [AnnotationDto].self
)
logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)")
return result
}
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto {
logger.debug("Creating annotation for bookmark: \(bookmarkId)")
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
let bodyDict: [String: Any] = [
"color": color,
"start_offset": startOffset,
"end_offset": endOffset,
"start_selector": startSelector,
"end_selector": endSelector
]
let bodyData = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
let result = try await makeJSONRequest(
endpoint: endpoint,
method: .POST,
body: bodyData,
responseType: AnnotationDto.self
)
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
return result
}
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
logger.info("Deleting annotation: \(annotationId) from bookmark: \(bookmarkId)")
let baseURL = await self.baseURL
let fullEndpoint = "/api/bookmarks/\(bookmarkId)/annotations/\(annotationId)"
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = await tokenProvider.getToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for DELETE \(url.absoluteString)")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
logger.info("Successfully deleted annotation: \(annotationId)")
}
} }
enum HTTPMethod: String { enum HTTPMethod: String {

View File

@ -1,21 +0,0 @@
import Foundation
struct AnnotationDto: Codable {
let id: String
let text: String
let created: String
let startOffset: Int
let endOffset: Int
let startSelector: String
let endSelector: String
enum CodingKeys: String, CodingKey {
case id
case text
case created
case startOffset = "start_offset"
case endOffset = "end_offset"
case startSelector = "start_selector"
case endSelector = "end_selector"
}
}

View File

@ -1,13 +0,0 @@
import Foundation
struct ServerInfoDto: Codable {
let version: String
let buildDate: String?
let userAgent: String?
enum CodingKeys: String, CodingKey {
case version
case buildDate = "build_date"
case userAgent = "user_agent"
}
}

View File

@ -1,55 +0,0 @@
//
// InfoApiClient.swift
// readeck
//
// Created by Claude Code
import Foundation
protocol PInfoApiClient {
func getServerInfo() async throws -> ServerInfoDto
}
class InfoApiClient: PInfoApiClient {
private let tokenProvider: TokenProvider
private let logger = Logger.network
init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
self.tokenProvider = tokenProvider
}
func getServerInfo() async throws -> ServerInfoDto {
guard let endpoint = await tokenProvider.getEndpoint(),
let url = URL(string: "\(endpoint)/api/info") else {
logger.error("Invalid endpoint URL for server info")
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "accept")
request.timeoutInterval = 5.0
if let token = await tokenProvider.getToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
}
logger.logNetworkRequest(method: "GET", url: url.absoluteString)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for server info")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
logger.logNetworkError(method: "GET", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
logger.logNetworkRequest(method: "GET", url: url.absoluteString, statusCode: httpResponse.statusCode)
return try JSONDecoder().decode(ServerInfoDto.self, from: data)
}
}

View File

@ -43,11 +43,6 @@ class CoreDataManager {
self?.logger.info("Core Data persistent store loaded successfully") self?.logger.info("Core Data persistent store loaded successfully")
} }
} }
// Configure viewContext for better extension support
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container return container
}() }()

View File

@ -14,7 +14,6 @@ extension BookmarkLabelDto {
func toEntity(context: NSManagedObjectContext) -> TagEntity { func toEntity(context: NSManagedObjectContext) -> TagEntity {
let entity = TagEntity(context: context) let entity = TagEntity(context: context)
entity.name = name entity.name = name
entity.count = Int32(count)
return entity return entity
} }
} }

View File

@ -1,28 +0,0 @@
import Foundation
class AnnotationsRepository: PAnnotationsRepository {
private let api: PAPI
init(api: PAPI) {
self.api = api
}
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] {
let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId)
return annotationDtos.map { dto in
Annotation(
id: dto.id,
text: dto.text,
created: dto.created,
startOffset: dto.startOffset,
endOffset: dto.endOffset,
startSelector: dto.startSelector,
endSelector: dto.endSelector
)
}
}
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -11,107 +11,34 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
} }
func getLabels() async throws -> [BookmarkLabel] { func getLabels() async throws -> [BookmarkLabel] {
// First, load from Core Data (instant response) let dtos = try await api.getBookmarkLabels()
let cachedLabels = try await loadLabelsFromCoreData() try? await saveLabels(dtos)
return dtos.map { $0.toDomain() }
// Then sync with API in background (don't wait)
Task.detached(priority: .background) { [weak self] in
guard let self = self else { return }
do {
let dtos = try await self.api.getBookmarkLabels()
try? await self.saveLabels(dtos)
} catch {
// Silent fail - we already have cached data
}
}
return cachedLabels
}
private func loadLabelsFromCoreData() async throws -> [BookmarkLabel] {
let backgroundContext = coreDataManager.newBackgroundContext()
return try await backgroundContext.perform {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "count", ascending: false),
NSSortDescriptor(key: "name", ascending: true)
]
let entities = try backgroundContext.fetch(fetchRequest)
return entities.compactMap { entity -> BookmarkLabel? in
guard let name = entity.name, !name.isEmpty else { return nil }
return BookmarkLabel(
name: name,
count: Int(entity.count),
href: name
)
}
}
} }
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws { func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
let backgroundContext = coreDataManager.newBackgroundContext() let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform { try await backgroundContext.perform { [weak self] in
// Batch fetch all existing labels guard let self = self else { return }
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name", "count"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
var existingByName: [String: TagEntity] = [:]
for entity in existingEntities {
if let name = entity.name {
existingByName[name] = entity
}
}
// Insert or update labels
var insertCount = 0
var updateCount = 0
for dto in dtos { for dto in dtos {
if let existing = existingByName[dto.name] { if !self.tagExists(name: dto.name, in: backgroundContext) {
// Update count if changed
if existing.count != dto.count {
existing.count = Int32(dto.count)
updateCount += 1
}
} else {
// Insert new label
dto.toEntity(context: backgroundContext) dto.toEntity(context: backgroundContext)
insertCount += 1
} }
} }
// Only save if there are changes
if insertCount > 0 || updateCount > 0 {
try backgroundContext.save() try backgroundContext.save()
} }
} }
}
func saveNewLabel(name: String) async throws { private func tagExists(name: String, in context: NSManagedObjectContext) -> Bool {
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// Check if label already exists
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest() let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName) fetchRequest.predicate = NSPredicate(format: "name == %@", name)
fetchRequest.fetchLimit = 1
let existingTags = try backgroundContext.fetch(fetchRequest) do {
let count = try context.count(for: fetchRequest)
// Only create if it doesn't exist return count > 0
if existingTags.isEmpty { } catch {
let newTag = TagEntity(context: backgroundContext) return false
newTag.name = trimmedName
newTag.count = 1 // New label is being used immediately
try backgroundContext.save()
}
} }
} }
} }

View File

@ -10,19 +10,16 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable {
private let coreDataManager = CoreDataManager.shared private let coreDataManager = CoreDataManager.shared
private let api: PAPI private let api: PAPI
private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase
init(api: PAPI = API(), init(api: PAPI = API()) {
checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) {
self.api = api self.api = api
self.checkServerReachabilityUseCase = checkServerReachabilityUseCase
} }
// MARK: - Sync Methods // MARK: - Sync Methods
func syncOfflineBookmarks() async { func syncOfflineBookmarks() async {
// First check if server is reachable // First check if server is reachable
guard await checkServerReachabilityUseCase.execute() else { guard await ServerConnectivity.isServerReachable() else {
await MainActor.run { await MainActor.run {
isSyncing = false isSyncing = false
syncStatus = "Server not reachable. Cannot sync." syncStatus = "Server not reachable. Cannot sync."
@ -124,4 +121,22 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable {
} }
} }
// MARK: - Auto Sync on Server Connectivity Changes
func startAutoSync() {
// Monitor server connectivity and auto-sync when server becomes reachable
NotificationCenter.default.addObserver(
forName: .serverDidBecomeAvailable,
object: nil,
queue: .main
) { [weak self] _ in
Task {
await self?.syncOfflineBookmarks()
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
} }

View File

@ -1,114 +0,0 @@
//
// ServerInfoRepository.swift
// readeck
//
// Created by Claude Code
import Foundation
class ServerInfoRepository: PServerInfoRepository {
private let apiClient: PInfoApiClient
private let logger = Logger.network
// Cache properties
private var cachedServerInfo: ServerInfo?
private var lastCheckTime: Date?
private let cacheTTL: TimeInterval = 30.0 // 30 seconds cache
private let rateLimitInterval: TimeInterval = 5.0 // min 5 seconds between requests
// Thread safety
private let queue = DispatchQueue(label: "com.readeck.serverInfoRepository", attributes: .concurrent)
init(apiClient: PInfoApiClient) {
self.apiClient = apiClient
}
func checkServerReachability() async -> Bool {
// Check cache first
if let cached = getCachedReachability() {
logger.debug("Server reachability from cache: \(cached)")
return cached
}
// Check rate limiting
if isRateLimited() {
logger.debug("Server reachability check rate limited, using cached value")
return cachedServerInfo?.isReachable ?? false
}
// Perform actual check
do {
let info = try await apiClient.getServerInfo()
let serverInfo = ServerInfo(from: info)
updateCache(serverInfo: serverInfo)
logger.info("Server reachability checked: true (version: \(info.version))")
return true
} catch {
let unreachableInfo = ServerInfo.unreachable
updateCache(serverInfo: unreachableInfo)
logger.warning("Server reachability check failed: \(error.localizedDescription)")
return false
}
}
func getServerInfo() async throws -> ServerInfo {
// Check cache first
if let cached = getCachedServerInfo() {
logger.debug("Server info from cache")
return cached
}
// Check rate limiting
if isRateLimited(), let cached = cachedServerInfo {
logger.debug("Server info check rate limited, using cached value")
return cached
}
// Fetch fresh info
let dto = try await apiClient.getServerInfo()
let serverInfo = ServerInfo(from: dto)
updateCache(serverInfo: serverInfo)
logger.info("Server info fetched: version \(dto.version)")
return serverInfo
}
// MARK: - Cache Management
private func getCachedReachability() -> Bool? {
queue.sync {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedServerInfo else {
return nil
}
return cached.isReachable
}
}
private func getCachedServerInfo() -> ServerInfo? {
queue.sync {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedServerInfo else {
return nil
}
return cached
}
}
private func isRateLimited() -> Bool {
queue.sync {
guard let lastCheck = lastCheckTime else {
return false
}
return Date().timeIntervalSince(lastCheck) < rateLimitInterval
}
}
private func updateCache(serverInfo: ServerInfo) {
queue.async(flags: .barrier) { [weak self] in
self?.cachedServerInfo = serverInfo
self?.lastCheckTime = Date()
}
}
}

View File

@ -1,6 +1,30 @@
import Foundation import Foundation
import CoreData import CoreData
struct Settings {
var endpoint: String? = nil
var username: String? = nil
var password: String? = nil
var token: String? = nil
var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var urlOpener: UrlOpener? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
}
mutating func setToken(_ newToken: String) {
token = newToken
}
}
protocol PSettingsRepository { protocol PSettingsRepository {
func saveSettings(_ settings: Settings) async throws func saveSettings(_ settings: Settings) async throws
func loadSettings() async throws -> Settings? func loadSettings() async throws -> Settings?
@ -12,8 +36,6 @@ protocol PSettingsRepository {
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
func loadCardLayoutStyle() async throws -> CardLayoutStyle func loadCardLayoutStyle() async throws -> CardLayoutStyle
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
func loadTagSortOrder() async throws -> TagSortOrder
var hasFinishedSetup: Bool { get } var hasFinishedSetup: Bool { get }
} }
@ -79,10 +101,6 @@ class SettingsRepository: PSettingsRepository {
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
} }
if let tagSortOrder = settings.tagSortOrder {
existingSettings.tagSortOrder = tagSortOrder.rawValue
}
try context.save() try context.save()
continuation.resume() continuation.resume()
} catch { } catch {
@ -121,7 +139,6 @@ class SettingsRepository: PSettingsRepository {
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),
tagSortOrder: TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue),
urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue) urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue)
) )
continuation.resume(returning: settings) continuation.resume(returning: settings)
@ -245,45 +262,4 @@ class SettingsRepository: PSettingsRepository {
} }
} }
} }
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
existingSettings.tagSortOrder = tagSortOrder.rawValue
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func loadTagSortOrder() async throws -> TagSortOrder {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first
let tagSortOrder = TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue) ?? .byCount
continuation.resume(returning: tagSortOrder)
} catch {
continuation.resume(throwing: error)
}
}
}
}
} }

View File

@ -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
} }
} }

View File

@ -1,12 +1,14 @@
import Foundation import Foundation
struct LabelUtils { struct LabelUtils {
/// Processes a label input string and returns it as a single trimmed label /// Splits a label input string by spaces and returns individual trimmed labels
/// - Parameter input: The input string containing a label (spaces are allowed) /// - Parameter input: The input string containing one or more labels separated by spaces
/// - Returns: Array containing the trimmed label, or empty array if input is empty /// - Returns: Array of individual trimmed labels, excluding empty strings
static func splitLabelsFromInput(_ input: String) -> [String] { static func splitLabelsFromInput(_ input: String) -> [String] {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) return input
return trimmed.isEmpty ? [] : [trimmed] .components(separatedBy: " ")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
} }
/// Filters out labels that already exist in current or available labels /// Filters out labels that already exist in current or available labels

View File

@ -0,0 +1,92 @@
import Foundation
import Network
class ServerConnectivity: ObservableObject {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue.global(qos: .background)
@Published var isServerReachable = false
static let shared = ServerConnectivity()
private init() {
startMonitoring()
}
private func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
if path.status == .satisfied {
// Network is available, now check server
Task {
let serverReachable = await ServerConnectivity.isServerReachable()
DispatchQueue.main.async {
let wasReachable = self?.isServerReachable ?? false
self?.isServerReachable = serverReachable
// Notify when server becomes available
if !wasReachable && serverReachable {
NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil)
}
}
}
} else {
DispatchQueue.main.async {
self?.isServerReachable = false
}
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
// Check if the Readeck server endpoint is reachable
static func isServerReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint + "/api/health") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 5.0 // 5 second timeout
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
// Fallback: try basic endpoint if health endpoint doesn't exist
return await isBasicEndpointReachable()
}
return false
}
private static func isBasicEndpointReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint) else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 3.0
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode < 500
}
} catch {
print("Server connectivity check failed: \(error)")
}
return false
}
}

View File

@ -1,19 +0,0 @@
import Foundation
struct Annotation: Identifiable, Hashable {
let id: String
let text: String
let created: String
let startOffset: Int
let endOffset: Int
let startSelector: String
let endSelector: String
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Annotation, rhs: Annotation) -> Bool {
lhs.id == rhs.id
}
}

View File

@ -1,23 +0,0 @@
//
// FontFamily.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}

View File

@ -1,33 +0,0 @@
//
// FontSize.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
import Foundation
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
}

View File

@ -1,21 +0,0 @@
import Foundation
struct ServerInfo {
let version: String
let buildDate: String?
let userAgent: String?
let isReachable: Bool
}
extension ServerInfo {
init(from dto: ServerInfoDto) {
self.version = dto.version
self.buildDate = dto.buildDate
self.userAgent = dto.userAgent
self.isReachable = true
}
static var unreachable: ServerInfo {
ServerInfo(version: "", buildDate: nil, userAgent: nil, isReachable: false)
}
}

View File

@ -1,32 +0,0 @@
//
// Settings.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
struct Settings {
var endpoint: String? = nil
var username: String? = nil
var password: String? = nil
var token: String? = nil
var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var tagSortOrder: TagSortOrder? = nil
var urlOpener: UrlOpener? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
}
mutating func setToken(_ newToken: String) {
token = newToken
}
}

View File

@ -1,20 +0,0 @@
//
// TagSortOrder.swift
// readeck
//
// Created by Ilyas Hallak
//
import Foundation
enum TagSortOrder: String, CaseIterable {
case byCount = "count"
case alphabetically = "alphabetically"
var displayName: String {
switch self {
case .byCount: return "By usage count"
case .alphabetically: return "Alphabetically"
}
}
}

View File

@ -1,10 +1,3 @@
//
// UrlOpener.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
enum UrlOpener: String, CaseIterable { enum UrlOpener: String, CaseIterable {
case inAppBrowser = "inAppBrowser" case inAppBrowser = "inAppBrowser"
case defaultBrowser = "defaultBrowser" case defaultBrowser = "defaultBrowser"

View File

@ -1,4 +0,0 @@
protocol PAnnotationsRepository {
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
}

View File

@ -3,5 +3,4 @@ import Foundation
protocol PLabelsRepository { protocol PLabelsRepository {
func getLabels() async throws -> [BookmarkLabel] func getLabels() async throws -> [BookmarkLabel]
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
func saveNewLabel(name: String) async throws
} }

View File

@ -1,10 +0,0 @@
//
// PServerInfoRepository.swift
// readeck
//
// Created by Claude Code
protocol PServerInfoRepository {
func checkServerReachability() async -> Bool
func getServerInfo() async throws -> ServerInfo
}

View File

@ -1,28 +0,0 @@
//
// CheckServerReachabilityUseCase.swift
// readeck
//
// Created by Claude Code
import Foundation
protocol PCheckServerReachabilityUseCase {
func execute() async -> Bool
func getServerInfo() async throws -> ServerInfo
}
class CheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
private let repository: PServerInfoRepository
init(repository: PServerInfoRepository) {
self.repository = repository
}
func execute() async -> Bool {
return await repository.checkServerReachability()
}
func getServerInfo() async throws -> ServerInfo {
return try await repository.getServerInfo()
}
}

View File

@ -1,17 +0,0 @@
import Foundation
protocol PCreateLabelUseCase {
func execute(name: String) async throws
}
class CreateLabelUseCase: PCreateLabelUseCase {
private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) {
self.labelsRepository = labelsRepository
}
func execute(name: String) async throws {
try await labelsRepository.saveNewLabel(name: name)
}
}

View File

@ -1,17 +0,0 @@
import Foundation
protocol PDeleteAnnotationUseCase {
func execute(bookmarkId: String, annotationId: String) async throws
}
class DeleteAnnotationUseCase: PDeleteAnnotationUseCase {
private let repository: PAnnotationsRepository
init(repository: PAnnotationsRepository) {
self.repository = repository
}
func execute(bookmarkId: String, annotationId: String) async throws {
try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -1,17 +0,0 @@
import Foundation
protocol PGetBookmarkAnnotationsUseCase {
func execute(bookmarkId: String) async throws -> [Annotation]
}
class GetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
private let repository: PAnnotationsRepository
init(repository: PAnnotationsRepository) {
self.repository = repository
}
func execute(bookmarkId: String) async throws -> [Annotation] {
return try await repository.fetchAnnotations(bookmarkId: bookmarkId)
}
}

View File

@ -1,21 +0,0 @@
import Foundation
protocol PSyncTagsUseCase {
func execute() async throws
}
/// Triggers background synchronization of tags from server to Core Data
/// Uses cache-first strategy - returns immediately after triggering sync
class SyncTagsUseCase: PSyncTagsUseCase {
private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) {
self.labelsRepository = labelsRepository
}
func execute() async throws {
// Trigger the sync - getLabels() uses cache-first + background sync strategy
// We don't need the return value, just triggering the sync is enough
_ = try await labelsRepository.getLabels()
}
}

View File

@ -40,11 +40,11 @@
"Tags" = "Labels"; "Tags" = "Labels";
/* Settings Sections */ /* Settings Sections */
"Font Settings" = "Schriftart"; "Font Settings" = "Schriftart-Einstellungen";
"Appearance" = "Darstellung"; "Appearance" = "Darstellung";
"Cache Settings" = "Cache"; "Cache Settings" = "Cache-Einstellungen";
"General Settings" = "Allgemein"; "General Settings" = "Allgemeine Einstellungen";
"Server Settings" = "Server"; "Server Settings" = "Server-Einstellungen";
"Server Connection" = "Server-Verbindung"; "Server Connection" = "Server-Verbindung";
"Open external links in" = "Öffne externe Links in"; "Open external links in" = "Öffne externe Links in";
"In App Browser" = "In App Browser"; "In App Browser" = "In App Browser";
@ -59,9 +59,6 @@
"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 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."; "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"; "Available tags" = "Verfügbare Labels";
"Most used tags" = "Meist verwendete Labels";
"Sorted by usage count" = "Sortiert nach Verwendungshäufigkeit";
"Sorted alphabetically" = "Alphabetisch sortiert";
"Cancel" = "Abbrechen"; "Cancel" = "Abbrechen";
"Category-specific Levels" = "Kategorie-spezifische Level"; "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)."; "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).";
@ -70,7 +67,7 @@
"Critical" = "Kritisch"; "Critical" = "Kritisch";
"Debug" = "Debug"; "Debug" = "Debug";
"DEBUG BUILD" = "DEBUG BUILD"; "DEBUG BUILD" = "DEBUG BUILD";
"Debug Settings" = "Debug"; "Debug Settings" = "Debug-Einstellungen";
"Delete" = "Löschen"; "Delete" = "Löschen";
"Delete Bookmark" = "Lesezeichen löschen"; "Delete Bookmark" = "Lesezeichen löschen";
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak"; "Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
@ -83,13 +80,13 @@
"Finished reading?" = "Fertig gelesen?"; "Finished reading?" = "Fertig gelesen?";
"Font" = "Schrift"; "Font" = "Schrift";
"Font family" = "Schriftart"; "Font family" = "Schriftart";
"Font Settings" = "Schrift"; "Font Settings" = "Schrift-Einstellungen";
"Font size" = "Schriftgröße"; "Font size" = "Schriftgröße";
"From Bremen with 💚" = "Aus Bremen mit 💚"; "From Bremen with 💚" = "Aus Bremen mit 💚";
"General" = "Allgemein"; "General" = "Allgemein";
"Global Level" = "Globales Level"; "Global Level" = "Globales Level";
"Global Minimum Level" = "Globales Minimum-Level"; "Global Minimum Level" = "Globales Minimum-Level";
"Global Settings" = "Global"; "Global Settings" = "Globale Einstellungen";
"https://example.com" = "https://example.com"; "https://example.com" = "https://example.com";
"https://readeck.example.com" = "https://readeck.example.com"; "https://readeck.example.com" = "https://readeck.example.com";
"Include Source Location" = "Quellort einschließen"; "Include Source Location" = "Quellort einschließen";
@ -108,8 +105,6 @@
"More" = "Mehr"; "More" = "Mehr";
"New Bookmark" = "Neues Lesezeichen"; "New Bookmark" = "Neues Lesezeichen";
"No articles in the queue" = "Keine Artikel in der Warteschlange"; "No articles in the queue" = "Keine Artikel in der Warteschlange";
"open_url" = "%@ öffnen";
"open_original_page" = "Originalseite öffnen";
"No bookmarks" = "Keine Lesezeichen"; "No bookmarks" = "Keine Lesezeichen";
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden."; "No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
"No bookmarks found." = "Keine Lesezeichen gefunden."; "No bookmarks found." = "Keine Lesezeichen gefunden.";

View File

@ -55,9 +55,6 @@
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone."; "Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup."; "Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
"Available tags" = "Available tags"; "Available tags" = "Available tags";
"Most used tags" = "Most used tags";
"Sorted by usage count" = "Sorted by usage count";
"Sorted alphabetically" = "Sorted alphabetically";
"Cancel" = "Cancel"; "Cancel" = "Cancel";
"Category-specific Levels" = "Category-specific Levels"; "Category-specific Levels" = "Category-specific Levels";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)."; "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
@ -104,8 +101,6 @@
"More" = "More"; "More" = "More";
"New Bookmark" = "New Bookmark"; "New Bookmark" = "New Bookmark";
"No articles in the queue" = "No articles in the queue"; "No articles in the queue" = "No articles in the queue";
"open_url" = "Open %@";
"open_original_page" = "Open original page";
"No bookmarks" = "No bookmarks"; "No bookmarks" = "No bookmarks";
"No bookmarks found in %@." = "No bookmarks found in %@."; "No bookmarks found in %@." = "No bookmarks found in %@.";
"No bookmarks found." = "No bookmarks found."; "No bookmarks found." = "No bookmarks found.";

View File

@ -10,7 +10,7 @@ import os
// MARK: - Log Configuration // MARK: - Log Configuration
enum LogLevel: Int, CaseIterable, Codable { enum LogLevel: Int, CaseIterable {
case debug = 0 case debug = 0
case info = 1 case info = 1
case notice = 2 case notice = 2
@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable, Codable {
} }
} }
enum LogCategory: String, CaseIterable, Codable { enum LogCategory: String, CaseIterable {
case network = "Network" case network = "Network"
case ui = "UI" case ui = "UI"
case data = "Data" case data = "Data"
@ -49,22 +49,8 @@ class LogConfiguration: ObservableObject {
@Published var showPerformanceLogs = true @Published var showPerformanceLogs = true
@Published var showTimestamps = true @Published var showTimestamps = true
@Published var includeSourceLocation = true @Published var includeSourceLocation = true
@Published var isLoggingEnabled = false
private init() { private init() {
// First time setup: Enable logging in DEBUG builds with sensible defaults
#if DEBUG
if UserDefaults.standard.object(forKey: "LogConfigurationInitialized") == nil {
isLoggingEnabled = true
showPerformanceLogs = true
showTimestamps = true
includeSourceLocation = true
globalMinLevel = .debug
UserDefaults.standard.set(true, forKey: "LogConfigurationInitialized")
saveConfiguration()
}
#endif
loadConfiguration() loadConfiguration()
} }
@ -78,7 +64,6 @@ class LogConfiguration: ObservableObject {
} }
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool { func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
guard isLoggingEnabled else { return false }
let categoryLevel = getLevel(for: category) let categoryLevel = getLevel(for: category)
return level.rawValue >= categoryLevel.rawValue return level.rawValue >= categoryLevel.rawValue
} }
@ -96,21 +81,10 @@ class LogConfiguration: ObservableObject {
} }
globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug
// Load boolean settings with defaults
if UserDefaults.standard.object(forKey: "LogShowPerformance") != nil {
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance") showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
}
if UserDefaults.standard.object(forKey: "LogShowTimestamps") != nil {
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps") showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
}
if UserDefaults.standard.object(forKey: "LogIncludeSourceLocation") != nil {
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation") includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
} }
if UserDefaults.standard.object(forKey: "LogIsEnabled") != nil {
isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled")
}
}
private func saveConfiguration() { private func saveConfiguration() {
let config = categoryLevels.mapKeys { $0.rawValue }.mapValues { $0.rawValue } let config = categoryLevels.mapKeys { $0.rawValue }.mapValues { $0.rawValue }
@ -122,7 +96,6 @@ class LogConfiguration: ObservableObject {
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance") UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps") UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation") UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled")
} }
} }
@ -142,61 +115,36 @@ struct Logger {
guard config.shouldLog(.debug, for: category) else { return } guard config.shouldLog(.debug, for: category) else { return }
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
logger.debug("\(formattedMessage)") logger.debug("\(formattedMessage)")
storeLog(message: message, level: .debug, file: file, function: function, line: line)
} }
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.info, for: category) else { return } guard config.shouldLog(.info, for: category) else { return }
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
logger.info("\(formattedMessage)") logger.info("\(formattedMessage)")
storeLog(message: message, level: .info, file: file, function: function, line: line)
} }
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.notice, for: category) else { return } guard config.shouldLog(.notice, for: category) else { return }
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
logger.notice("\(formattedMessage)") logger.notice("\(formattedMessage)")
storeLog(message: message, level: .notice, file: file, function: function, line: line)
} }
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.warning, for: category) else { return } guard config.shouldLog(.warning, for: category) else { return }
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
logger.warning("\(formattedMessage)") logger.warning("\(formattedMessage)")
storeLog(message: message, level: .warning, file: file, function: function, line: line)
} }
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.error, for: category) else { return } guard config.shouldLog(.error, for: category) else { return }
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
logger.error("\(formattedMessage)") logger.error("\(formattedMessage)")
storeLog(message: message, level: .error, file: file, function: function, line: line)
} }
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.critical, for: category) else { return } guard config.shouldLog(.critical, for: category) else { return }
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
logger.critical("\(formattedMessage)") logger.critical("\(formattedMessage)")
storeLog(message: message, level: .critical, file: file, function: function, line: line)
}
// MARK: - Store Log
private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) {
#if DEBUG
guard config.isLoggingEnabled else { return }
let entry = LogEntry(
level: level,
category: category,
message: message,
file: file,
function: function,
line: line
)
Task {
await LogStore.shared.addEntry(entry)
}
#endif
} }
// MARK: - Convenience Methods // MARK: - Convenience Methods

View File

@ -4,8 +4,6 @@ import UIKit
struct AddBookmarkView: View { struct AddBookmarkView: View {
@State private var viewModel = AddBookmarkViewModel() @State private var viewModel = AddBookmarkViewModel()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject private var appSettings: AppSettings
@FocusState private var focusedField: AddBookmarkFieldFocus? @FocusState private var focusedField: AddBookmarkFieldFocus?
@State private var keyboardHeight: CGFloat = 0 @State private var keyboardHeight: CGFloat = 0
@ -60,9 +58,9 @@ struct AddBookmarkView: View {
} }
.onAppear { .onAppear {
viewModel.checkClipboard() viewModel.checkClipboard()
Task {
await viewModel.syncTags()
} }
.task {
await viewModel.loadAllLabels()
} }
.onDisappear { .onDisappear {
viewModel.clearForm() viewModel.clearForm()
@ -179,18 +177,13 @@ struct AddBookmarkView: View {
@ViewBuilder @ViewBuilder
private var labelsField: some View { private var labelsField: some View {
VStack(alignment: .leading, spacing: 8) { TagManagementView(
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized) allLabels: viewModel.allLabels,
.font(.caption)
.foregroundColor(.secondary)
CoreDataTagManagementView(
selectedLabels: viewModel.selectedLabels, selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText, searchText: $viewModel.searchText,
isLabelsLoading: viewModel.isLabelsLoading,
filteredLabels: viewModel.filteredLabels,
searchFieldFocus: $focusedField, searchFieldFocus: $focusedField,
fetchLimit: nil,
sortOrder: appSettings.tagSortOrder,
context: viewContext,
onAddCustomTag: { onAddCustomTag: {
viewModel.addCustomTag() viewModel.addCustomTag()
}, },
@ -202,7 +195,6 @@ struct AddBookmarkView: View {
} }
) )
} }
}
@ViewBuilder @ViewBuilder
private var bottomActionArea: some View { private var bottomActionArea: some View {

View File

@ -8,8 +8,6 @@ class AddBookmarkViewModel {
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase() private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase() private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
private let createLabelUseCase = DefaultUseCaseFactory.shared.makeCreateLabelUseCase()
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
// MARK: - Form Data // MARK: - Form Data
var url: String = "" var url: String = ""
@ -63,13 +61,6 @@ class AddBookmarkViewModel {
// MARK: - Labels Management // MARK: - Labels Management
/// Triggers background sync of tags from server to Core Data
/// CoreDataTagManagementView will automatically update via @FetchRequest
@MainActor
func syncTags() async {
try? await syncTagsUseCase.execute()
}
@MainActor @MainActor
func loadAllLabels() async { func loadAllLabels() async {
isLabelsLoading = true isLabelsLoading = true
@ -99,11 +90,6 @@ class AddBookmarkViewModel {
} else { } else {
selectedLabels.insert(trimmed) selectedLabels.insert(trimmed)
searchText = "" searchText = ""
// Save new label to Core Data so it's available next time
Task {
try? await createLabelUseCase.execute(name: trimmed)
}
} }
} }

View File

@ -8,24 +8,19 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
@MainActor class AppViewModel: ObservableObject {
@Observable
class AppViewModel {
private let settingsRepository = SettingsRepository() private let settingsRepository = SettingsRepository()
private let factory: UseCaseFactory private let logoutUseCase: LogoutUseCase
private let syncTagsUseCase: PSyncTagsUseCase
var hasFinishedSetup: Bool = true @Published var hasFinishedSetup: Bool = true
var isServerReachable: Bool = false
private var lastAppStartTagSyncTime: Date? init(logoutUseCase: LogoutUseCase = LogoutUseCase()) {
self.logoutUseCase = logoutUseCase
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.factory = factory
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
setupNotificationObservers() setupNotificationObservers()
loadSetupStatus() Task {
await loadSetupStatus()
}
} }
private func setupNotificationObservers() { private func setupNotificationObservers() {
@ -34,7 +29,7 @@ class AppViewModel {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
Task { @MainActor in Task {
await self?.handleUnauthorizedResponse() await self?.handleUnauthorizedResponse()
} }
} }
@ -44,17 +39,19 @@ class AppViewModel {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
Task { @MainActor in
self?.loadSetupStatus() self?.loadSetupStatus()
} }
} }
}
@MainActor
private func handleUnauthorizedResponse() async { private func handleUnauthorizedResponse() async {
print("AppViewModel: Handling 401 Unauthorized - logging out user") print("AppViewModel: Handling 401 Unauthorized - logging out user")
do { do {
try await factory.makeLogoutUseCase().execute() // Führe den Logout durch
try await logoutUseCase.execute()
// Update UI state
loadSetupStatus() loadSetupStatus()
print("AppViewModel: User successfully logged out due to 401 error") print("AppViewModel: User successfully logged out due to 401 error")
@ -63,35 +60,11 @@ class AppViewModel {
} }
} }
@MainActor
private func loadSetupStatus() { private func loadSetupStatus() {
hasFinishedSetup = settingsRepository.hasFinishedSetup hasFinishedSetup = settingsRepository.hasFinishedSetup
} }
func onAppResume() async {
await checkServerReachability()
await syncTagsOnAppStart()
}
private func checkServerReachability() async {
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
}
private func syncTagsOnAppStart() async {
let now = Date()
// Check if last sync was less than 2 minutes ago
if let lastSync = lastAppStartTagSyncTime,
now.timeIntervalSince(lastSync) < 120 {
print("AppViewModel: Skipping tag sync - last sync was less than 2 minutes ago")
return
}
// Sync tags from server to Core Data
print("AppViewModel: Syncing tags on app start")
try? await syncTagsUseCase.execute()
lastAppStartTagSyncTime = now
}
deinit { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }

View File

@ -1,45 +0,0 @@
import SwiftUI
struct AnnotationColorOverlay: View {
let onColorSelected: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 8) {
ForEach(Constants.annotationColors, id: \.self) { color in
ColorButton(color: color, onTap: onColorSelected)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2)
)
}
private struct ColorButton: View {
let color: AnnotationColor
let onTap: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: { onTap(color) }) {
Circle()
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
.frame(width: 36, height: 36)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
)
}
}
}
}
#Preview {
AnnotationColorOverlay { color in
print("Selected: \(color)")
}
.padding()
}

View File

@ -1,63 +0,0 @@
import SwiftUI
struct AnnotationColorPicker: View {
let selectedText: String
let onColorSelected: (AnnotationColor) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text("Highlight Text")
.font(.headline)
Text(selectedText)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(3)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
Text("Select Color")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 16) {
ForEach(Constants.annotationColors, id: \.self) { color in
ColorButton(color: color, onTap: handleColorSelection)
}
}
Button("Cancel") {
dismiss()
}
.foregroundColor(.secondary)
}
.padding(24)
.frame(maxWidth: 400)
}
private func handleColorSelection(_ color: AnnotationColor) {
onColorSelected(color)
dismiss()
}
}
struct ColorButton: View {
let color: AnnotationColor
let onTap: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: { onTap(color) }) {
Circle()
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.2), lineWidth: 1)
)
}
}
}

View File

@ -1,131 +0,0 @@
import SwiftUI
struct AnnotationsListView: View {
let bookmarkId: String
@State private var viewModel = AnnotationsListViewModel()
@Environment(\.dismiss) private var dismiss
var onAnnotationTap: ((String) -> Void)?
enum ViewState {
case loading
case empty
case loaded([Annotation])
case error(String)
}
private var viewState: ViewState {
if viewModel.isLoading {
return .loading
} else if let error = viewModel.errorMessage, viewModel.showErrorAlert {
return .error(error)
} else if viewModel.annotations.isEmpty {
return .empty
} else {
return .loaded(viewModel.annotations)
}
}
var body: some View {
List {
switch viewState {
case .loading:
HStack {
Spacer()
ProgressView()
Spacer()
}
case .empty:
ContentUnavailableView(
"No Annotations",
systemImage: "pencil.slash",
description: Text("This bookmark has no annotations yet.")
)
case .loaded(let annotations):
ForEach(annotations) { annotation in
Button(action: {
onAnnotationTap?(annotation.id)
dismiss()
}) {
VStack(alignment: .leading, spacing: 8) {
if !annotation.text.isEmpty {
Text(annotation.text)
.font(.body)
.foregroundColor(.primary)
}
Text(formatDate(annotation.created))
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task {
await viewModel.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotation.id)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
case .error:
EmptyView()
}
}
.navigationTitle("Annotations")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
}
}
}
.task {
await viewModel.loadAnnotations(for: bookmarkId)
}
.alert("Error", isPresented: $viewModel.showErrorAlert) {
Button("OK", role: .cancel) {}
} message: {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
}
}
}
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 {
NavigationStack {
AnnotationsListView(bookmarkId: "123")
}
}

View File

@ -1,42 +0,0 @@
import Foundation
@Observable
class AnnotationsListViewModel {
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
private let deleteAnnotationUseCase: PDeleteAnnotationUseCase
var annotations: [Annotation] = []
var isLoading = false
var errorMessage: String?
var showErrorAlert = false
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
self.deleteAnnotationUseCase = factory.makeDeleteAnnotationUseCase()
}
@MainActor
func loadAnnotations(for bookmarkId: String) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
annotations = try await getAnnotationsUseCase.execute(bookmarkId: bookmarkId)
} catch {
errorMessage = "Failed to load annotations"
showErrorAlert = true
}
}
@MainActor
func deleteAnnotation(bookmarkId: String, annotationId: String) async {
do {
try await deleteAnnotationUseCase.execute(bookmarkId: bookmarkId, annotationId: annotationId)
annotations.removeAll { $0.id == annotationId }
} catch {
errorMessage = "Failed to delete annotation"
showErrorAlert = true
}
}
}

View File

@ -1,598 +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 showingAnnotationsSheet = 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
}
},
selectedAnnotationId: viewModel.selectedAnnotationId,
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
Task {
await viewModel.createAnnotation(
bookmarkId: bookmarkId,
color: color,
text: text,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
},
onScrollToPosition: { position in
// Calculate scroll position: add header height and webview offset
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
let targetPosition = imageHeight + position
// Scroll to the annotation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollPosition = ScrollPosition(y: targetPosition)
}
}
)
.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.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.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: {
showingAnnotationsSheet = true
}) {
Image(systemName: "pencil.line")
}
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: $showingAnnotationsSheet) {
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
viewModel.selectedAnnotationId = annotationId
}
}
.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: showingAnnotationsSheet) { _, 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
}
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
// Trigger WebView reload when annotation is selected
}
.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.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.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.openUrlLabel(for: viewModel.bookmarkDetail.url))
.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())
)
}
}

View File

@ -1,30 +1,462 @@
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(), showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId
self.viewModel = viewModel
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
}
}, onScroll: { progress in
// Handle scroll progress if needed
})
.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: "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 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() -> 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()),
showingFontSettings: false,
showingLabelsSheet: false,
playerUIState: .init())
} }
} }

View File

@ -1,566 +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 showingAnnotationsSheet = 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: $showingAnnotationsSheet) {
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
viewModel.selectedAnnotationId = annotationId
}
}
.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: showingAnnotationsSheet) { _, isShowing in
if !isShowing {
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
// Trigger WebView reload when annotation is selected
}
.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")
}
if viewModel.hasAnnotations {
Button(action: {
showingAnnotationsSheet = true
}) {
Image(systemName: "pencil.line")
}
}
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.openUrlLabel(for: viewModel.bookmarkDetail.url))
.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
}
},
selectedAnnotationId: viewModel.selectedAnnotationId,
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
Task {
await viewModel.createAnnotation(
bookmarkId: bookmarkId,
color: color,
text: text,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
},
onScrollToPosition: { position in
// Calculate scroll position: add header height and webview offset
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
let targetPosition = imageHeight + position
// Scroll to the annotation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollPosition = ScrollPosition(y: targetPosition)
}
}
)
.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.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.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())
)
}
}
}

View File

@ -8,7 +8,6 @@ class BookmarkDetailViewModel {
private let loadSettingsUseCase: PLoadSettingsUseCase private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase? private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
private let api: PAPI
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = "" var articleContent: String = ""
@ -19,8 +18,6 @@ class BookmarkDetailViewModel {
var errorMessage: String? var errorMessage: String?
var settings: Settings? var settings: Settings?
var readProgress: Int = 0 var readProgress: Int = 0
var selectedAnnotationId: String?
var hasAnnotations: Bool = false
private var factory: UseCaseFactory? private var factory: UseCaseFactory?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -31,7 +28,6 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.api = API()
self.factory = factory self.factory = factory
readProgressSubject readProgressSubject
@ -88,9 +84,6 @@ class BookmarkDetailViewModel {
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
articleParagraphs = paragraphs articleParagraphs = paragraphs
// Check if article contains annotations
hasAnnotations = articleContent.contains("<rd-annotation")
} }
@MainActor @MainActor
@ -144,22 +137,4 @@ class BookmarkDetailViewModel {
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) { func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
readProgressSubject.send((id, progress, anchor)) readProgressSubject.send((id, progress, anchor))
} }
@MainActor
func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async {
do {
let annotation = try await api.createAnnotation(
bookmarkId: bookmarkId,
color: color,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
print("✅ Annotation created: \(annotation.id)")
} catch {
print("❌ Failed to create annotation: \(error)")
errorMessage = "Error creating annotation"
}
}
} }

View File

@ -4,8 +4,6 @@ struct BookmarkLabelsView: View {
let bookmarkId: String let bookmarkId: String
@State private var viewModel: BookmarkLabelsViewModel @State private var viewModel: BookmarkLabelsViewModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject private var appSettings: AppSettings
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) { init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) {
self.bookmarkId = bookmarkId self.bookmarkId = bookmarkId
@ -42,15 +40,13 @@ struct BookmarkLabelsView: View {
} message: { } message: {
Text(viewModel.errorMessage ?? "Unknown error") Text(viewModel.errorMessage ?? "Unknown error")
} }
.task {
await viewModel.loadAllLabels()
}
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.onTapGesture { .onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
} }
.onAppear {
Task {
await viewModel.syncTags()
}
}
} }
} }
@ -60,18 +56,12 @@ struct BookmarkLabelsView: View {
@ViewBuilder @ViewBuilder
private var availableLabelsSection: some View { private var availableLabelsSection: some View {
VStack(alignment: .leading, spacing: 8) { TagManagementView(
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized) allLabels: viewModel.allLabels,
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
CoreDataTagManagementView(
selectedLabels: Set(viewModel.currentLabels), selectedLabels: Set(viewModel.currentLabels),
searchText: $viewModel.searchText, searchText: $viewModel.searchText,
fetchLimit: nil, isLabelsLoading: viewModel.isInitialLoading,
sortOrder: appSettings.tagSortOrder, filteredLabels: viewModel.filteredLabels,
context: viewContext,
onAddCustomTag: { onAddCustomTag: {
Task { Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
@ -90,7 +80,6 @@ struct BookmarkLabelsView: View {
) )
.padding(.horizontal) .padding(.horizontal)
} }
}
} }
#Preview { #Preview {

View File

@ -5,7 +5,6 @@ class BookmarkLabelsViewModel {
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
private let getLabelsUseCase: PGetLabelsUseCase private let getLabelsUseCase: PGetLabelsUseCase
private let syncTagsUseCase: PSyncTagsUseCase
var isLoading = false var isLoading = false
var isInitialLoading = false var isInitialLoading = false
@ -35,14 +34,7 @@ class BookmarkLabelsViewModel {
self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase() self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase()
self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase() self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase()
self.getLabelsUseCase = factory.makeGetLabelsUseCase() self.getLabelsUseCase = factory.makeGetLabelsUseCase()
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
}
/// Triggers background sync of tags from server to Core Data
/// CoreDataTagManagementView will automatically update via @FetchRequest
@MainActor
func syncTags() async {
try? await syncTagsUseCase.execute()
} }
@MainActor @MainActor

View File

@ -254,7 +254,7 @@ struct BookmarkCardView: View {
} }
} }
HStack { HStack {
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari") Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
.onTapGesture { .onTapGesture {
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener) URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
} }
@ -335,7 +335,7 @@ struct BookmarkCardView: View {
} }
} }
HStack { HStack {
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari") Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
.onTapGesture { .onTapGesture {
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener) URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
} }

View File

@ -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)

View File

@ -26,9 +26,6 @@ class BookmarksViewModel {
// Undo delete functionality // Undo delete functionality
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
// Prevent concurrent updates
private var isUpdating = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var limit = 50 private var limit = 50
@ -107,10 +104,6 @@ class BookmarksViewModel {
@MainActor @MainActor
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async { func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
guard !isUpdating else { return }
isUpdating = true
defer { isUpdating = false }
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
currentState = state currentState = state
@ -156,9 +149,7 @@ class BookmarksViewModel {
@MainActor @MainActor
func loadMoreBookmarks() async { func loadMoreBookmarks() async {
guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads guard !isLoading && hasMoreData else { return } // prevent multiple loads
isUpdating = true
defer { isUpdating = false }
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil

View File

@ -10,53 +10,7 @@
// //
import Foundation import Foundation
import SwiftUI
struct Constants { struct Constants {
// Annotation colors // Empty for now - can be used for other constants in the future
static let annotationColors: [AnnotationColor] = [.yellow, .green, .blue, .red]
}
enum AnnotationColor: String, CaseIterable, Codable {
case yellow = "yellow"
case green = "green"
case blue = "blue"
case red = "red"
// Base hex color for buttons and overlays
var hexColor: String {
switch self {
case .yellow: return "#D4A843"
case .green: return "#6FB546"
case .blue: return "#4A9BB8"
case .red: return "#C84848"
}
}
// RGB values for SwiftUI Color
private var rgb: (red: Double, green: Double, blue: Double) {
switch self {
case .yellow: return (212, 168, 67)
case .green: return (111, 181, 70)
case .blue: return (74, 155, 184)
case .red: return (200, 72, 72)
}
}
func swiftUIColor(isDark: Bool) -> Color {
let (r, g, b) = rgb
return Color(red: r/255, green: g/255, blue: b/255)
}
// CSS rgba string for JavaScript (for highlighting)
func cssColor(isDark: Bool) -> String {
let (r, g, b) = rgb
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), 0.3)"
}
// CSS rgba string with custom opacity
func cssColorWithOpacity(_ opacity: Double) -> String {
let (r, g, b) = rgb
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), \(opacity))"
}
} }

View File

@ -1,330 +0,0 @@
import SwiftUI
import CoreData
struct CoreDataTagManagementView: View {
// MARK: - Properties
let selectedLabelsSet: Set<String>
let searchText: Binding<String>
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
let sortOrder: TagSortOrder
let availableLabelsTitle: String?
let context: NSManagedObjectContext
// MARK: - Callbacks
let onAddCustomTag: () -> Void
let onToggleLabel: (String) -> Void
let onRemoveLabel: (String) -> Void
// MARK: - FetchRequest
@FetchRequest
private var tagEntities: FetchedResults<TagEntity>
// MARK: - Search State
@State private var searchResults: [TagEntity] = []
@State private var isSearchActive: Bool = false
// MARK: - Initialization
init(
selectedLabels: Set<String>,
searchText: Binding<String>,
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
fetchLimit: Int? = nil,
sortOrder: TagSortOrder = .byCount,
availableLabelsTitle: String? = nil,
context: NSManagedObjectContext,
onAddCustomTag: @escaping () -> Void,
onToggleLabel: @escaping (String) -> Void,
onRemoveLabel: @escaping (String) -> Void
) {
self.selectedLabelsSet = selectedLabels
self.searchText = searchText
self.searchFieldFocus = searchFieldFocus
self.sortOrder = sortOrder
self.availableLabelsTitle = availableLabelsTitle
self.context = context
self.onAddCustomTag = onAddCustomTag
self.onToggleLabel = onToggleLabel
self.onRemoveLabel = onRemoveLabel
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
// Apply sort order from parameter
let sortDescriptors: [NSSortDescriptor]
switch sortOrder {
case .byCount:
sortDescriptors = [
NSSortDescriptor(keyPath: \TagEntity.count, ascending: false),
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
]
case .alphabetically:
sortDescriptors = [
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
]
}
fetchRequest.sortDescriptors = sortDescriptors
if let limit = fetchLimit {
fetchRequest.fetchLimit = limit
}
fetchRequest.fetchBatchSize = 20
_tagEntities = FetchRequest(
fetchRequest: fetchRequest,
animation: .default
)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
searchField
customTagSuggestion
availableLabels
selectedLabels
}
.onChange(of: searchText.wrappedValue) { oldValue, newValue in
performSearch(query: newValue)
}
}
// MARK: - View Components
@ViewBuilder
private var searchField: some View {
TextField("Search or add new label...", text: searchText)
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.default)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onSubmit {
onAddCustomTag()
}
.modifier(FocusModifier(focusBinding: searchFieldFocus, field: .labels))
}
@ViewBuilder
private var customTagSuggestion: some View {
if !searchText.wrappedValue.isEmpty &&
!allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) &&
!selectedLabelsSet.contains(searchText.wrappedValue) {
HStack {
Text("Add new label:")
.font(.subheadline)
.foregroundColor(.secondary)
Text(searchText.wrappedValue)
.font(.subheadline)
.fontWeight(.medium)
Spacer()
Button(action: onAddCustomTag) {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill")
.font(.subheadline)
Text("Add")
.font(.subheadline)
.fontWeight(.medium)
}
}
.foregroundColor(.accentColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(Color.accentColor.opacity(0.1))
.cornerRadius(10)
}
}
@ViewBuilder
private var availableLabels: some View {
if !tagEntities.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(searchText.wrappedValue.isEmpty ? (availableLabelsTitle ?? "Available labels") : "Search results")
.font(.subheadline)
.fontWeight(.medium)
if !searchText.wrappedValue.isEmpty {
Text("(\(filteredTagsCount) found)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
if availableUnselectedTagsCount == 0 {
// Show "All labels selected" only if there are actually filtered results
// Otherwise show "No labels found" for empty search results
if filteredTagsCount > 0 {
VStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
.foregroundColor(.green)
Text("All labels selected")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else if !searchText.wrappedValue.isEmpty {
VStack {
Image(systemName: "magnifyingglass")
.font(.system(size: 24))
.foregroundColor(.secondary)
Text("No labels found")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
}
} else {
labelsScrollView
}
}
.padding(.top, 8)
}
}
@ViewBuilder
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(
rows: [
GridItem(.fixed(32), spacing: 8),
GridItem(.fixed(32), spacing: 8),
GridItem(.fixed(32), spacing: 8)
],
alignment: .top,
spacing: 8
) {
// Use searchResults when search is active, otherwise use tagEntities
let tagsToDisplay = isSearchActive ? searchResults : Array(tagEntities)
ForEach(tagsToDisplay, id: \.objectID) { entity in
if let name = entity.name {
// When searching, show all results (already filtered by predicate)
// When not searching, filter with shouldShowTag()
let shouldShow = isSearchActive ? !selectedLabelsSet.contains(name) : shouldShowTag(name)
if shouldShow {
UnifiedLabelChip(
label: name,
isSelected: false,
isRemovable: false,
onTap: {
onToggleLabel(name)
}
)
.fixedSize(horizontal: true, vertical: false)
}
}
}
}
.frame(height: 120) // 3 rows * 32px + 2 * 8px spacing
.padding(.horizontal)
}
}
// MARK: - Computed Properties & Helper Functions
private var allTagNames: [String] {
tagEntities.compactMap { $0.name }
}
private var filteredTagsCount: Int {
if isSearchActive {
return searchResults.count
} else if searchText.wrappedValue.isEmpty {
return tagEntities.count
} else {
return tagEntities.filter { entity in
guard let name = entity.name else { return false }
return name.localizedCaseInsensitiveContains(searchText.wrappedValue)
}.count
}
}
private var availableUnselectedTagsCount: Int {
if isSearchActive {
return searchResults.filter { entity in
guard let name = entity.name else { return false }
return !selectedLabelsSet.contains(name)
}.count
} else {
return tagEntities.filter { entity in
guard let name = entity.name else { return false }
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
let isNotSelected = !selectedLabelsSet.contains(name)
return matchesSearch && isNotSelected
}.count
}
}
private func shouldShowTag(_ name: String) -> Bool {
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
let isNotSelected = !selectedLabelsSet.contains(name)
return matchesSearch && isNotSelected
}
private func performSearch(query: String) {
guard !query.isEmpty else {
isSearchActive = false
searchResults = []
return
}
// Search directly in Core Data without fetchLimit
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", query)
// Use same sort order as main fetch
let sortDescriptors: [NSSortDescriptor]
switch sortOrder {
case .byCount:
sortDescriptors = [
NSSortDescriptor(keyPath: \TagEntity.count, ascending: false),
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
]
case .alphabetically:
sortDescriptors = [
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
]
}
fetchRequest.sortDescriptors = sortDescriptors
// NO fetchLimit - search ALL tags in database
searchResults = (try? context.fetch(fetchRequest)) ?? []
isSearchActive = true
}
@ViewBuilder
private var selectedLabels: some View {
if !selectedLabelsSet.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Selected labels")
.font(.subheadline)
.fontWeight(.medium)
FlowLayout(spacing: 8) {
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: true,
isRemovable: true,
onTap: {
// No action for selected labels
},
onRemove: {
onRemoveLabel(label)
}
)
}
}
}
.padding(.top, 8)
}
}
}

View File

@ -1,717 +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
var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
var onScrollToPosition: ((CGFloat) -> 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()
setupAnnotationMessageHandler()
setupScrollToPositionHandler()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
}
.onChange(of: colorScheme) { _, _ in
loadStyledContent()
}
.onChange(of: selectedAnnotationId) { _, _ 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 setupAnnotationMessageHandler() {
guard let onAnnotationCreated = onAnnotationCreated else { return }
// Poll for annotation messages from JavaScript
Task { @MainActor in
let page = webPage
while true {
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
let script = """
return (function() {
if (window.__pendingAnnotation) {
const data = window.__pendingAnnotation;
window.__pendingAnnotation = null;
return data;
}
return null;
})();
"""
do {
if let result = try await page.callJavaScript(script) as? [String: Any],
let color = result["color"] as? String,
let text = result["text"] as? String,
let startOffset = result["startOffset"] as? Int,
let endOffset = result["endOffset"] as? Int,
let startSelector = result["startSelector"] as? String,
let endSelector = result["endSelector"] as? String {
onAnnotationCreated(color, text, startOffset, endOffset, startSelector, endSelector)
}
} catch {
// Silently continue polling
}
}
}
}
private func setupScrollToPositionHandler() {
guard let onScrollToPosition = onScrollToPosition else { return }
// Poll for scroll position messages from JavaScript
Task { @MainActor in
let page = webPage
while true {
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
let script = """
return (function() {
if (window.__pendingScrollPosition !== undefined) {
const position = window.__pendingScrollPosition;
window.__pendingScrollPosition = undefined;
return position;
}
return null;
})();
"""
do {
if let position = try await page.callJavaScript(script) as? Double {
onScrollToPosition(CGFloat(position))
}
} catch {
// Silently continue polling
}
}
}
}
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; }
/* Annotation Highlighting - for rd-annotation tags */
rd-annotation {
border-radius: 3px;
padding: 2px 0;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
/* Yellow annotations */
rd-annotation[data-annotation-color="yellow"] {
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="yellow"].selected {
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
}
/* Green annotations */
rd-annotation[data-annotation-color="green"] {
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="green"].selected {
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
}
/* Blue annotations */
rd-annotation[data-annotation-color="blue"] {
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="blue"].selected {
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
}
/* Red annotations */
rd-annotation[data-annotation-color="red"] {
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="red"].selected {
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
}
</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();
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</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"
}
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
return """
// Create annotation color overlay
(function() {
let currentSelection = null;
let currentRange = null;
let selectionTimeout = null;
// Create overlay container with arrow
const overlay = document.createElement('div');
overlay.id = 'annotation-overlay';
overlay.style.cssText = `
display: none;
position: absolute;
z-index: 10000;
`;
// Create arrow/triangle pointing up with glass effect
const arrow = document.createElement('div');
arrow.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
border-bottom: none;
top: -11px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
overlay.appendChild(arrow);
// Create the actual content container with glass morphism effect
const content = document.createElement('div');
content.style.cssText = `
display: flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
gap: 12px;
flex-direction: row;
align-items: center;
`;
overlay.appendChild(content);
// Add "Markierung" label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.style.cssText = `
color: black;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
`;
content.appendChild(label);
// Create color buttons with solid colors
const colors = [
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
];
colors.forEach(({ name, color }) => {
const btn = document.createElement('button');
btn.dataset.color = name;
btn.style.cssText = `
width: 40px;
height: 40px;
border-radius: 50%;
background: ${color};
border: 3px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0;
margin: 0;
transition: transform 0.2s, border-color 0.2s;
`;
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
});
btn.addEventListener('click', () => handleColorSelection(name));
content.appendChild(btn);
});
document.body.appendChild(overlay);
// Selection change listener
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 0) {
currentSelection = text;
currentRange = selection.getRangeAt(0).cloneRange();
showOverlay(selection.getRangeAt(0));
} else {
hideOverlay();
}
}, 150);
});
function showOverlay(range) {
const rect = range.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
overlay.style.display = 'block';
// Center horizontally under selection
const overlayWidth = 320; // Approximate width with label + 4 buttons
const centerX = rect.left + (rect.width / 2);
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
// Position with extra space below selection (55px instead of 70px) to bring it closer
const topPos = rect.bottom + scrollY + 55;
overlay.style.left = leftPos + 'px';
overlay.style.top = topPos + 'px';
}
function hideOverlay() {
overlay.style.display = 'none';
currentSelection = null;
currentRange = null;
}
function calculateOffset(container, offset) {
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(container, offset);
return preRange.toString().length;
}
function getXPathSelector(node) {
// If node is text node, use parent element
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (!element || element === document.body) return 'body';
const path = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
// Count position among siblings of same tag (1-based index)
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
// Format: tagname[index] (1-based)
path.unshift(tagName + '[' + index + ']');
current = current.parentElement;
}
const selector = path.join('/');
console.log('Generated selector:', selector);
return selector || 'body';
}
function calculateOffsetInElement(container, offset) {
// Calculate offset relative to the parent element (not document.body)
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (!element) return offset;
// Create range from start of element to the position
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(container, offset);
return range.toString().length;
}
function generateTempId() {
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function handleColorSelection(color) {
if (!currentRange || !currentSelection) return;
// Generate XPath-like selectors for start and end containers
const startSelector = getXPathSelector(currentRange.startContainer);
const endSelector = getXPathSelector(currentRange.endContainer);
// Calculate offsets relative to the element (not document.body)
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
// Create annotation element
const annotation = document.createElement('rd-annotation');
annotation.setAttribute('data-annotation-color', color);
annotation.setAttribute('data-annotation-id-value', generateTempId());
// Wrap selection in annotation
try {
currentRange.surroundContents(annotation);
} catch (e) {
// If surroundContents fails (e.g., partial element selection), extract and wrap
const fragment = currentRange.extractContents();
annotation.appendChild(fragment);
currentRange.insertNode(annotation);
}
// For NativeWebView: use global variable for polling
window.__pendingAnnotation = {
color: color,
text: currentSelection,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
};
// Clear selection and hide overlay
window.getSelection().removeAllRanges();
hideOverlay();
}
})();
"""
}
private func generateScrollToAnnotationJS() -> String {
guard let selectedId = selectedAnnotationId else {
return ""
}
return """
// Scroll to selected annotation and add selected class
function scrollToAnnotation() {
// Remove 'selected' class from all annotations
document.querySelectorAll('rd-annotation.selected').forEach(el => {
el.classList.remove('selected');
});
// Find and highlight selected annotation
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) {
selectedElement.classList.add('selected');
// Get the element's position relative to the document
const rect = selectedElement.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elementTop = rect.top + scrollTop;
// Send position to Swift via polling mechanism
setTimeout(() => {
window.__pendingScrollPosition = elementTop;
}, 100);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
} else {
setTimeout(scrollToAnnotation, 300);
}
"""
}
}
// 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
)
}
}
}
*/

View File

@ -1,308 +0,0 @@
//
// SettingsRow.swift
// readeck
//
// Created by Ilyas Hallak on 31.10.25.
//
import SwiftUI
// MARK: - Settings Row with Navigation Link
struct SettingsRowNavigationLink<Destination: View>: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let destination: Destination
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
@ViewBuilder destination: () -> Destination
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.destination = destination()
}
var body: some View {
NavigationLink(destination: destination) {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: subtitle
)
}
}
}
// MARK: - Settings Row with Toggle
struct SettingsRowToggle: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
@Binding var isOn: Bool
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
isOn: Binding<Bool>
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self._isOn = isOn
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: subtitle
)
Toggle("", isOn: $isOn)
.labelsHidden()
}
}
}
// MARK: - Settings Row with Value Display
struct SettingsRowValue: View {
let icon: String?
let iconColor: Color
let title: String
let value: String
let valueColor: Color
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
value: String,
valueColor: Color = .secondary
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.value = value
self.valueColor = valueColor
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: nil
)
Spacer()
Text(value)
.foregroundColor(valueColor)
}
}
}
// MARK: - Settings Row Button (for actions)
struct SettingsRowButton: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let destructive: Bool
let action: () -> Void
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
destructive: Bool = false,
action: @escaping () -> Void
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.destructive = destructive
self.action = action
}
var body: some View {
Button(action: action) {
SettingsRowLabel(
icon: icon,
iconColor: destructive ? .red : iconColor,
title: title,
subtitle: subtitle,
titleColor: destructive ? .red : .primary
)
}
}
}
// MARK: - Settings Row with Picker
struct SettingsRowPicker<T: Hashable>: View {
let icon: String?
let iconColor: Color
let title: String
let selection: Binding<T>
let options: [(value: T, label: String)]
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
selection: Binding<T>,
options: [(value: T, label: String)]
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.selection = selection
self.options = options
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: nil
)
Spacer()
Picker("", selection: selection) {
ForEach(options, id: \.value) { option in
Text(option.label).tag(option.value)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
}
}
// MARK: - Settings Row Label (internal component)
struct SettingsRowLabel: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let titleColor: Color
init(
icon: String?,
iconColor: Color,
title: String,
subtitle: String?,
titleColor: Color = .primary
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.titleColor = titleColor
}
var body: some View {
HStack(spacing: 12) {
if let icon = icon {
Image(systemName: icon)
.foregroundColor(iconColor)
.frame(width: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.foregroundColor(titleColor)
if let subtitle = subtitle {
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Previews
#Preview("Navigation Link") {
List {
SettingsRowNavigationLink(
icon: "paintbrush",
title: "App Icon",
subtitle: nil
) {
Text("Detail View")
}
}
.listStyle(.insetGrouped)
}
#Preview("Toggle") {
List {
SettingsRowToggle(
icon: "speaker.wave.2",
title: "Read Aloud Feature",
subtitle: "Text-to-Speech functionality",
isOn: .constant(true)
)
}
.listStyle(.insetGrouped)
}
#Preview("Value Display") {
List {
SettingsRowValue(
icon: "paintbrush.fill",
iconColor: .purple,
title: "Tint Color",
value: "Purple"
)
}
.listStyle(.insetGrouped)
}
#Preview("Button") {
List {
SettingsRowButton(
icon: "trash",
iconColor: .red,
title: "Clear Cache",
subtitle: "Remove all cached images",
destructive: true
) {
print("Clear cache tapped")
}
}
.listStyle(.insetGrouped)
}
#Preview("Picker") {
List {
SettingsRowPicker(
icon: "textformat",
title: "Font Family",
selection: .constant("System"),
options: [
("System", "System"),
("Serif", "Serif"),
("Monospace", "Monospace")
]
)
}
.listStyle(.insetGrouped)
}

View File

@ -1,7 +1,3 @@
// TODO: deprecated - This file is no longer used and can be removed
// Replaced by CoreDataTagManagementView.swift which uses Core Data directly
// instead of fetching labels via API
import SwiftUI import SwiftUI
struct FlowLayout: Layout { struct FlowLayout: Layout {
@ -79,7 +75,7 @@ struct FocusModifier: ViewModifier {
} }
} }
struct LegacyTagManagementView: View { struct TagManagementView: View {
// MARK: - Properties // MARK: - Properties
@ -218,7 +214,7 @@ struct LegacyTagManagementView: View {
@ViewBuilder @ViewBuilder
private var labelsScrollView: some View { private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(chunkedLabels, id: \.self) { rowLabels in ForEach(chunkedLabels, id: \.self) { rowLabels in
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
ForEach(rowLabels, id: \.id) { label in ForEach(rowLabels, id: \.id) { label in

View File

@ -1,14 +1,253 @@
import SwiftUI import SwiftUI
import WebKit import WebKit
struct WebView: UIViewRepresentable { // iOS 26+ Native SwiftUI WebView Implementation
@available(iOS 26.0, *)
struct NativeWebView: View {
let htmlContent: String
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
var body: some View {
WebKit.WebView(webPage)
.scrollDisabled(true) // Disable internal scrolling
.onAppear {
loadStyledContent()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
}
.onChange(of: colorScheme) { _, _ in
loadStyledContent()
}
.onChange(of: webPage.isLoading) { _, isLoading in
if !isLoading {
// Update height when content finishes loading
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
Task {
await updateContentHeightWithJS()
}
}
}
}
}
private func updateContentHeightWithJS() async {
var lastHeight: CGFloat = 0
// More frequent attempts with shorter delays
let delays = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0] // 9 attempts
for (index, delay) in delays.enumerated() {
let attempt = index + 1
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
do {
// Try to get height via JavaScript
let result = try await webPage.callJavaScript("getContentHeight()")
if let height = result as? Double, height > 0 {
let cgHeight = CGFloat(height)
// Update height if it's significantly different or this is the first valid measurement
if lastHeight == 0 || abs(cgHeight - lastHeight) > 10 {
print("JavaScript height updated: \(height)px on attempt \(attempt)")
DispatchQueue.main.async {
self.onHeightChange(cgHeight)
}
lastHeight = cgHeight
}
// If height seems stable (no change in last few attempts), we can exit early
if attempt >= 3 && lastHeight > 0 {
print("Height stabilized at \(lastHeight)px")
return
}
}
} catch {
print("JavaScript attempt \(attempt) failed: \(error)")
}
}
// If no valid height was found, use fallback
if lastHeight == 0 {
print("No valid JavaScript height found, using fallback")
updateContentHeightFallback()
}
}
private func updateContentHeightFallback() {
// Simplified fallback calculation
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
let plainText = htmlContent.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
let characterCount = plainText.count
let estimatedLines = max(1, characterCount / 80)
let textHeight = CGFloat(estimatedLines) * CGFloat(fontSize) * 1.8
let finalHeight = max(400, min(textHeight + 100, 3000))
DispatchQueue.main.async {
self.onHeightChange(finalHeight)
}
}
private func loadStyledContent() {
let isDarkMode = colorScheme == .dark
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
let styledHTML = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
<style>
body {
font-family: \(fontFamily);
line-height: 1.8;
margin: 0;
padding: 16px;
background-color: \(isDarkMode ? "#000000" : "#ffffff");
color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
font-size: \(fontSize)px;
-webkit-text-size-adjust: 100%;
-webkit-user-select: text;
user-select: text;
overflow: hidden; /* Disable scrolling in WebView */
height: auto;
}
html {
overflow: hidden; /* Disable scrolling */
height: auto;
}
h1, h2, h3, h4, h5, h6 {
color: \(isDarkMode ? "#ffffff" : "#000000");
margin-top: 24px;
margin-bottom: 12px;
font-weight: 600;
}
img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; }
a { color: \(isDarkMode ? "#0A84FF" : "#007AFF"); text-decoration: none; }
blockquote { border-left: 4px solid \(isDarkMode ? "#0A84FF" : "#007AFF"); margin: 16px 0; padding: 12px 16px; color: \(isDarkMode ? "#8E8E93" : "#666666"); background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)"); border-radius: 4px; }
code { background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5"); color: \(isDarkMode ? "#ffffff" : "#000000"); padding: 2px 6px; border-radius: 4px; font-family: 'SF Mono', monospace; }
</style>
</head>
<body>
\(htmlContent)
<script>
function measureHeight() {
return Math.max(
document.body.scrollHeight || 0,
document.body.offsetHeight || 0,
document.documentElement.clientHeight || 0,
document.documentElement.scrollHeight || 0,
document.documentElement.offsetHeight || 0
);
}
// Make function globally available
window.getContentHeight = measureHeight;
// Auto-measure when everything is ready
function scheduleHeightCheck() {
// Multiple timing strategies
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', delayedHeightCheck);
} else {
delayedHeightCheck();
}
// Also check after images load
window.addEventListener('load', delayedHeightCheck);
// Force check after layout
setTimeout(delayedHeightCheck, 50);
setTimeout(delayedHeightCheck, 100);
setTimeout(delayedHeightCheck, 200);
setTimeout(delayedHeightCheck, 500);
}
function delayedHeightCheck() {
// Force layout recalculation
document.body.offsetHeight;
const height = measureHeight();
console.log('Height check:', height);
}
scheduleHeightCheck();
</script>
</body>
</html>
"""
webPage.load(html: styledHTML)
// Update height after content loads
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
await updateContentHeightWithJS()
}
}
}
private func getFontSize(from fontSize: FontSize) -> Int {
switch fontSize {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
private func getFontFamily(from fontFamily: FontFamily) -> String {
switch fontFamily {
case .system: return "-apple-system, BlinkMacSystemFont, sans-serif"
case .serif: return "'Times New Roman', Times, serif"
case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif"
case .monospace: return "'SF Mono', Menlo, Monaco, monospace"
}
}
}
// Main WebView - automatically chooses best implementation
struct WebView: View {
let htmlContent: String
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var body: some View {
if #available(iOS 26.0, *) {
// Use new native SwiftUI WebView on iOS 26+
NativeWebView(
htmlContent: htmlContent,
settings: settings,
onHeightChange: onHeightChange,
onScroll: onScroll
)
} else {
// Fallback to WKWebView wrapper for older iOS
LegacyWebView(
htmlContent: htmlContent,
settings: settings,
onHeightChange: onHeightChange,
onScroll: onScroll
)
}
}
}
// Fallback: Original WKWebView Implementation
struct LegacyWebView: UIViewRepresentable {
let htmlContent: String let htmlContent: String
let settings: Settings let settings: Settings
let onHeightChange: (CGFloat) -> Void let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
var onScrollToPosition: ((CGFloat) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView { func makeUIView(context: Context) -> WKWebView {
@ -29,45 +268,26 @@ struct WebView: UIViewRepresentable {
webView.allowsBackForwardNavigationGestures = false webView.allowsBackForwardNavigationGestures = false
webView.allowsLinkPreview = true webView.allowsLinkPreview = true
// Message Handler für Height und Scroll Updates
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate") webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress") webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
webView.configuration.userContentController.add(context.coordinator, name: "scrollToPosition")
context.coordinator.onHeightChange = onHeightChange context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.onScrollToPosition = onScrollToPosition
context.coordinator.webView = webView
return webView return webView
} }
func updateUIView(_ webView: WKWebView, context: Context) { func updateUIView(_ webView: WKWebView, context: Context) {
// Update callbacks
context.coordinator.onHeightChange = onHeightChange context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.onScrollToPosition = onScrollToPosition
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>
@ -245,84 +465,31 @@ struct WebView: UIViewRepresentable {
--separator-color: #e0e0e0; --separator-color: #e0e0e0;
} }
} }
/* Annotation Highlighting - for rd-annotation tags */
rd-annotation {
border-radius: 3px;
padding: 2px 0;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
/* Yellow annotations */
rd-annotation[data-annotation-color="yellow"] {
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="yellow"].selected {
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
}
/* Green annotations */
rd-annotation[data-annotation-color="green"] {
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="green"].selected {
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
}
/* Blue annotations */
rd-annotation[data-annotation-color="blue"] {
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="blue"].selected {
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
}
/* Red annotations */
rd-annotation[data-annotation-color="red"] {
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="red"].selected {
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
}
</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 to selected annotation // Scroll progress reporting
\(generateScrollToAnnotationJS()) window.addEventListener('scroll', function() {
var scrollTop = window.scrollY || document.documentElement.scrollTop;
// Text Selection and Annotation Overlay var docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode)) var progress = docHeight > 0 ? scrollTop / docHeight : 0;
window.webkit.messageHandlers.scrollProgress.postMessage(progress);
});
</script> </script>
</body> </body>
</html> </html>
@ -330,17 +497,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.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition")
webView.loadHTMLString("", baseURL: nil)
coordinator.cleanup()
}
func makeCoordinator() -> WebViewCoordinator { func makeCoordinator() -> WebViewCoordinator {
WebViewCoordinator() WebViewCoordinator()
} }
@ -366,320 +522,18 @@ struct WebView: UIViewRepresentable {
return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace" return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace"
} }
} }
private func generateScrollToAnnotationJS() -> String {
guard let selectedId = selectedAnnotationId else {
return ""
}
return """
// Scroll to selected annotation and add selected class
function scrollToAnnotation() {
// Remove 'selected' class from all annotations
document.querySelectorAll('rd-annotation.selected').forEach(el => {
el.classList.remove('selected');
});
// Find and highlight selected annotation
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) {
selectedElement.classList.add('selected');
// Get the element's position relative to the document
const rect = selectedElement.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elementTop = rect.top + scrollTop;
// Send position to Swift
setTimeout(() => {
window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop);
}, 100);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
} else {
setTimeout(scrollToAnnotation, 300);
}
"""
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
let yellowColor = AnnotationColor.yellow.cssColor(isDark: isDarkMode)
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
return """
// Create annotation color overlay
(function() {
let currentSelection = null;
let currentRange = null;
let selectionTimeout = null;
// Create overlay container with arrow
const overlay = document.createElement('div');
overlay.id = 'annotation-overlay';
overlay.style.cssText = `
display: none;
position: absolute;
z-index: 10000;
`;
// Create arrow/triangle pointing up with glass effect
const arrow = document.createElement('div');
arrow.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
border-bottom: none;
top: -11px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
overlay.appendChild(arrow);
// Create the actual content container with glass morphism effect
const content = document.createElement('div');
content.style.cssText = `
display: flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
gap: 12px;
flex-direction: row;
align-items: center;
`;
overlay.appendChild(content);
// Add "Markierung" label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.style.cssText = `
color: black;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
`;
content.appendChild(label);
// Create color buttons with solid colors
const colors = [
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
];
colors.forEach(({ name, color }) => {
const btn = document.createElement('button');
btn.dataset.color = name;
btn.style.cssText = `
width: 40px;
height: 40px;
border-radius: 50%;
background: ${color};
border: 3px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0;
margin: 0;
transition: transform 0.2s, border-color 0.2s;
`;
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
});
btn.addEventListener('click', () => handleColorSelection(name));
content.appendChild(btn);
});
document.body.appendChild(overlay);
// Selection change listener
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 0) {
currentSelection = text;
currentRange = selection.getRangeAt(0).cloneRange();
showOverlay(selection.getRangeAt(0));
} else {
hideOverlay();
}
}, 150);
});
function showOverlay(range) {
const rect = range.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
overlay.style.display = 'block';
// Center horizontally under selection
const overlayWidth = 320; // Approximate width with label + 4 buttons
const centerX = rect.left + (rect.width / 2);
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
// Position with extra space below selection (55px instead of 70px) to bring it closer
const topPos = rect.bottom + scrollY + 55;
overlay.style.left = leftPos + 'px';
overlay.style.top = topPos + 'px';
}
function hideOverlay() {
overlay.style.display = 'none';
currentSelection = null;
currentRange = null;
}
function calculateOffset(container, offset) {
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(container, offset);
return preRange.toString().length;
}
function getXPathSelector(node) {
// If node is text node, use parent element
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (!element || element === document.body) return 'body';
const path = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
// Count position among siblings of same tag (1-based index)
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
// Format: tagname[index] (1-based)
path.unshift(tagName + '[' + index + ']');
current = current.parentElement;
}
const selector = path.join('/');
console.log('Generated selector:', selector);
return selector || 'body';
}
function calculateOffsetInElement(container, offset) {
// Calculate offset relative to the parent element (not document.body)
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (!element) return offset;
// Create range from start of element to the position
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(container, offset);
return range.toString().length;
}
function generateTempId() {
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function handleColorSelection(color) {
if (!currentRange || !currentSelection) return;
// Generate XPath-like selectors for start and end containers
const startSelector = getXPathSelector(currentRange.startContainer);
const endSelector = getXPathSelector(currentRange.endContainer);
// Calculate offsets relative to the element (not document.body)
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
// Create annotation element
const annotation = document.createElement('rd-annotation');
annotation.setAttribute('data-annotation-color', color);
annotation.setAttribute('data-annotation-id-value', generateTempId());
// Wrap selection in annotation
try {
currentRange.surroundContents(annotation);
} catch (e) {
// If surroundContents fails (e.g., partial element selection), extract and wrap
const fragment = currentRange.extractContents();
annotation.appendChild(fragment);
currentRange.insertNode(annotation);
}
// Send to Swift with selectors
window.webkit.messageHandlers.annotationCreated.postMessage({
color: color,
text: currentSelection,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
});
// Clear selection and hide overlay
window.getSelection().removeAllRanges();
hideOverlay();
}
})();
"""
}
} }
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 onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? var hasHeightUpdate: Bool = false
var onScrollToPosition: ((CGFloat) -> Void)?
// WebView reference
weak var webView: WKWebView?
// Height management
var lastHeight: CGFloat = 0
var pendingHeight: CGFloat = 0
var heightUpdateTimer: Timer?
// Scroll management
var isScrolling: Bool = false var isScrolling: Bool = false
var scrollVelocity: Double = 0
var lastScrollTime: Date = Date()
var scrollEndTimer: Timer? var scrollEndTimer: Timer?
// Lifecycle
private var isCleanedUp = false
deinit { deinit {
cleanup() scrollEndTimer?.invalidate()
scrollEndTimer = nil
} }
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@ -694,108 +548,34 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
} }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "heightUpdate", let height = message.body as? CGFloat { switch message.name {
case "heightUpdate":
guard let height = message.body as? CGFloat else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
self.handleHeightUpdate(height: height) // Block height updates during active scrolling to prevent flicker
if !self.isScrolling && !self.hasHeightUpdate {
self.onHeightChange?(height)
self.hasHeightUpdate = true
} }
} }
if message.name == "scrollProgress", let progress = message.body as? Double {
case "scrollProgress":
guard let progress = message.body as? Double else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
self.handleScrollProgress(progress: progress) // Track scrolling state
} self.isScrolling = true
}
if message.name == "annotationCreated", let body = message.body as? [String: Any], // Reset scrolling state after scroll ends
let color = body["color"] as? String, self.scrollEndTimer?.invalidate()
let text = body["text"] as? String, self.scrollEndTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] _ in
let startOffset = body["startOffset"] as? Int, self?.isScrolling = false
let endOffset = body["endOffset"] as? Int,
let startSelector = body["startSelector"] as? String,
let endSelector = body["endSelector"] as? String {
DispatchQueue.main.async {
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
}
}
if message.name == "scrollToPosition", let position = message.body as? Double {
DispatchQueue.main.async {
self.onScrollToPosition?(CGFloat(position))
}
}
} }
private func handleHeightUpdate(height: CGFloat) { self.onScroll?(progress)
// 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 default:
applyHeightUpdate(height: height) print("Unknown message: \(message.name)")
} }
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
onAnnotationCreated = nil
onScrollToPosition = nil
} }
} }

View File

@ -1,14 +0,0 @@
//
// FontSizeExtension.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
import SwiftUI
extension FontSize {
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -16,15 +16,10 @@ protocol UseCaseFactory {
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> PGetLabelsUseCase func makeGetLabelsUseCase() -> PGetLabelsUseCase
func makeCreateLabelUseCase() -> PCreateLabelUseCase
func makeSyncTagsUseCase() -> PSyncTagsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
} }
@ -35,9 +30,6 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository) private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api) private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
private let settingsRepository: PSettingsRepository = SettingsRepository() private let settingsRepository: PSettingsRepository = SettingsRepository()
private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider)
private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient)
private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api)
static let shared = DefaultUseCaseFactory() static let shared = DefaultUseCaseFactory()
@ -105,18 +97,6 @@ class DefaultUseCaseFactory: UseCaseFactory {
return GetLabelsUseCase(labelsRepository: labelsRepository) return GetLabelsUseCase(labelsRepository: labelsRepository)
} }
func makeCreateLabelUseCase() -> PCreateLabelUseCase {
let api = API(tokenProvider: KeychainTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return CreateLabelUseCase(labelsRepository: labelsRepository)
}
func makeSyncTagsUseCase() -> PSyncTagsUseCase {
let api = API(tokenProvider: KeychainTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return SyncTagsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase { func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase() return AddTextToSpeechQueueUseCase()
} }
@ -132,16 +112,4 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase { func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
return SaveCardLayoutUseCase(settingsRepository: settingsRepository) return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
} }
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase {
return CheckServerReachabilityUseCase(repository: serverInfoRepository)
}
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
}
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
return DeleteAnnotationUseCase(repository: annotationsRepository)
}
} }

View File

@ -9,10 +9,6 @@ import Foundation
import Combine import Combine
class MockUseCaseFactory: UseCaseFactory { class MockUseCaseFactory: UseCaseFactory {
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
MockCheckServerReachabilityUseCase()
}
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase { func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
MockOfflineBookmarkSyncUseCase() MockOfflineBookmarkSyncUseCase()
} }
@ -77,14 +73,6 @@ class MockUseCaseFactory: UseCaseFactory {
MockGetLabelsUseCase() MockGetLabelsUseCase()
} }
func makeCreateLabelUseCase() -> any PCreateLabelUseCase {
MockCreateLabelUseCase()
}
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
MockSyncTagsUseCase()
}
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase { func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
MockAddTextToSpeechQueueUseCase() MockAddTextToSpeechQueueUseCase()
} }
@ -96,14 +84,6 @@ class MockUseCaseFactory: UseCaseFactory {
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase { func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
MockSaveCardLayoutUseCase() MockSaveCardLayoutUseCase()
} }
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
MockGetBookmarkAnnotationsUseCase()
}
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
MockDeleteAnnotationUseCase()
}
} }
@ -133,18 +113,6 @@ class MockGetLabelsUseCase: PGetLabelsUseCase {
} }
} }
class MockCreateLabelUseCase: PCreateLabelUseCase {
func execute(name: String) async throws {
// Mock implementation - does nothing
}
}
class MockSyncTagsUseCase: PSyncTagsUseCase {
func execute() async throws {
// Mock implementation - does nothing
}
}
class MockSearchBookmarksUseCase: PSearchBookmarksUseCase { class MockSearchBookmarksUseCase: PSearchBookmarksUseCase {
func execute(search: String) async throws -> BookmarksPage { func execute(search: String) async throws -> BookmarksPage {
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil) BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)
@ -256,30 +224,6 @@ class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
} }
} }
class MockCheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
func execute() async -> Bool {
return true
}
func getServerInfo() async throws -> ServerInfo {
return ServerInfo(version: "1.0.0", buildDate: nil, userAgent: nil, isReachable: true)
}
}
class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
func execute(bookmarkId: String) async throws -> [Annotation] {
return [
.init(id: "1", text: "bla", created: "", startOffset: 0, endOffset: 1, startSelector: "", endSelector: "")
]
}
}
class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase {
func execute(bookmarkId: String, annotationId: String) async throws {
// Mock implementation - do nothing
}
}
extension Bookmark { extension Bookmark {
static let mock: Bookmark = .init( static let mock: Bookmark = .init(
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil) id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)

View File

@ -11,8 +11,8 @@ class OfflineBookmarksViewModel {
private let successDelaySubject = PassthroughSubject<Int, Never>() private let successDelaySubject = PassthroughSubject<Int, Never>()
private var completionTimerActive = false private var completionTimerActive = false
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase() self.syncUseCase = syncUseCase
setupBindings() setupBindings()
refreshState() refreshState()
} }

View File

@ -13,7 +13,7 @@ struct PadSidebarView: View {
@State private var selectedTag: BookmarkLabel? @State private var selectedTag: BookmarkLabel?
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings @EnvironmentObject var appSettings: AppSettings
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel() @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags] private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
@ -87,11 +87,11 @@ struct PadSidebarView: View {
case .all: case .all:
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .unread: case .unread:
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
case .favorite: case .favorite:
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
case .archived: case .archived:
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
case .settings: case .settings:
SettingsView() SettingsView()
case .article: case .article:

View File

@ -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 offlineBookmarksViewModel = OfflineBookmarksViewModel() @State private var selectedTabIndex: Int = 1
@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
ZStack {
// Hidden NavigationLink to remove disclosure indicator
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)
} }
@ -234,13 +115,13 @@ struct PhoneTabView: View {
case .all: case .all:
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .unread: case .unread:
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil))
case .favorite: case .favorite:
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
case .archived: case .archived:
BookmarksView(state: .archived, type: [.article, .video, .photo], 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
}
}
}

View File

@ -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 {

View File

@ -31,10 +31,6 @@ class AppSettings: ObservableObject {
settings?.urlOpener ?? .inAppBrowser settings?.urlOpener ?? .inAppBrowser
} }
var tagSortOrder: TagSortOrder {
settings?.tagSortOrder ?? .byCount
}
init(settings: Settings? = nil) { init(settings: Settings? = nil) {
self.settings = settings self.settings = settings
} }

View File

@ -1,175 +0,0 @@
//
// OnboardingServerView.swift
// readeck
//
// Created by Ilyas Hallak on 31.10.25.
//
import SwiftUI
struct OnboardingServerView: View {
@State private var viewModel = SettingsServerViewModel()
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Server Settings".localized, icon: "server.rack")
.padding(.bottom, 4)
Text("Enter your Readeck server details to get started.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
// Form
VStack(spacing: 16) {
// Server Endpoint
VStack(alignment: .leading, spacing: 8) {
TextField("",
text: $viewModel.endpoint,
prompt: Text("Server Endpoint").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.endpoint) {
viewModel.clearMessages()
}
// Quick Input Chips
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
QuickInputChip(text: "http://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "http://" + viewModel.endpoint
}
})
QuickInputChip(text: "https://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "https://" + viewModel.endpoint
}
})
QuickInputChip(text: "192.168.", action: {
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
if viewModel.endpoint.starts(with: "http") {
viewModel.endpoint += "192.168."
} else {
viewModel.endpoint = "http://192.168."
}
}
})
QuickInputChip(text: ":8000", action: {
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
viewModel.endpoint += ":8000"
}
})
}
.padding(.horizontal, 1)
}
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Username
VStack(alignment: .leading, spacing: 8) {
TextField("",
text: $viewModel.username,
prompt: Text("Username").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.username) {
viewModel.clearMessages()
}
}
// Password
VStack(alignment: .leading, spacing: 8) {
SecureField("",
text: $viewModel.password,
prompt: Text("Password").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.password) {
viewModel.clearMessages()
}
}
}
// Messages
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
}
}
VStack(spacing: 10) {
Button(action: {
Task {
await viewModel.saveServerSettings()
}
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canLogin || viewModel.isLoading)
}
}
.task {
await viewModel.loadServerSettings()
}
}
}
// MARK: - Quick Input Chip Component
struct QuickInputChip: View {
let text: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(text)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(12)
}
}
}
#Preview {
OnboardingServerView()
.padding()
}

View File

@ -1,134 +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.2.0
### Annotations & Highlighting
- **Highlight important passages** directly in your articles
- Select text to bring up a beautiful color picker overlay
- Choose from four distinct colors: yellow, green, blue, and red
- Your highlights are saved and synced across devices
- Tap on annotations in the list to jump directly to that passage in the article
- Glass morphism design for a modern, elegant look
### Performance Improvements
- **Dramatically faster label loading** - especially with 1000+ labels
- Labels now load instantly, even without internet connection
- Share Extension loads much faster
- Better performance when working with many labels
- Improved overall app stability
### Settings Redesign
- **Completely redesigned settings screen** with native iOS style
- Font settings moved to dedicated screen with larger preview
- Reorganized sections for better overview
- Inline explanations directly under settings
- Cleaner app info footer with muted styling
- Combined legal, privacy and support into one section
### Tag Management Improvements
- **Handles 1000+ tags smoothly** - no more lag or slowdowns
- **Tags now load from local database** - no internet required
- Choose your preferred tag sorting: by usage count or alphabetically
- Tags sync automatically in the background
- Share Extension shows your 150 most-used tags instantly
- Better offline support for managing tags
- Faster and more responsive tag selection
### Fixes & Improvements
- Better color consistency throughout the app
- Improved text selection in articles
- Better formatted release notes
- Various bug fixes and stability improvements
---
## Version 1.1.0
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

View File

@ -3,121 +3,68 @@ import SwiftUI
struct AppearanceSettingsView: View { struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine @State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system @State private var selectedTheme: Theme = .system
@State private var selectedTagSortOrder: TagSortOrder = .byCount
@State private var fontViewModel: FontSettingsViewModel
@State private var generalViewModel: SettingsGeneralViewModel
@EnvironmentObject private var appSettings: AppSettings
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
private let settingsRepository: PSettingsRepository private let settingsRepository: PSettingsRepository
init( init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
factory: UseCaseFactory = DefaultUseCaseFactory.shared,
fontViewModel: FontSettingsViewModel = FontSettingsViewModel(),
generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()
) {
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase() self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase() self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
self.settingsRepository = SettingsRepository() self.settingsRepository = SettingsRepository()
self.fontViewModel = fontViewModel
self.generalViewModel = generalViewModel
} }
var body: some View { var body: some View {
Group { VStack(spacing: 20) {
Section { SectionHeader(title: "Appearance".localized, icon: "paintbrush")
// Font Settings als NavigationLink .padding(.bottom, 4)
NavigationLink {
FontSelectionView(viewModel: fontViewModel)
} label: {
HStack {
Text("Font")
Spacer()
Text("\(fontViewModel.selectedFontFamily.displayName) · \(fontViewModel.selectedFontSize.displayName)")
.foregroundColor(.secondary)
}
}
// Theme Picker (Menu statt Segmented) // Theme Section
VStack(alignment: .leading, spacing: 12) {
Text("Theme")
.font(.headline)
Picker("Theme", selection: $selectedTheme) { Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme) Text(theme.displayName).tag(theme)
} }
} }
.pickerStyle(.segmented)
.onChange(of: selectedTheme) { .onChange(of: selectedTheme) {
saveThemeSettings() saveThemeSettings()
} }
}
// Card Layout als NavigationLink Divider()
NavigationLink {
CardLayoutSelectionView( // Card Layout Section
selectedCardLayout: $selectedCardLayout, VStack(alignment: .leading, spacing: 12) {
onSave: saveCardLayoutSettings
)
} label: {
HStack {
Text("Card Layout") Text("Card Layout")
Spacer() .font(.headline)
Text(selectedCardLayout.displayName)
.foregroundColor(.secondary)
}
}
// Open external links in VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) { ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
Picker("Open links in", selection: $generalViewModel.urlOpener) { CardLayoutPreview(
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in layout: layout,
Text(urlOpener.displayName).tag(urlOpener) isSelected: selectedCardLayout == layout
) {
selectedCardLayout = layout
saveCardLayoutSettings()
} }
} }
.onChange(of: generalViewModel.urlOpener) {
Task {
await generalViewModel.saveGeneralSettings()
} }
} }
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
} }
.onAppear {
// Tag Sort Order
VStack(alignment: .leading, spacing: 4) {
Picker("Tag sort order", selection: $selectedTagSortOrder) {
ForEach(TagSortOrder.allCases, id: \.self) { sortOrder in
Text(sortOrder.displayName).tag(sortOrder)
}
}
.onChange(of: selectedTagSortOrder) {
saveTagSortOrderSettings()
}
Text("Determines how tags are displayed when adding or editing bookmarks.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
} header: {
Text("Appearance")
}
}
.task {
await fontViewModel.loadFontSettings()
await generalViewModel.loadGeneralSettings()
loadSettings() loadSettings()
} }
} }
private func loadSettings() { private func loadSettings() {
Task { Task {
// Load theme, card layout, and tag sort order from repository // Load both theme and card layout from repository
if let settings = try? await settingsRepository.loadSettings() { if let settings = try? await settingsRepository.loadSettings() {
await MainActor.run { await MainActor.run {
selectedTheme = settings.theme ?? .system selectedTheme = settings.theme ?? .system
selectedTagSortOrder = settings.tagSortOrder ?? .byCount
} }
} }
selectedCardLayout = await loadCardLayoutUseCase.execute() selectedCardLayout = await loadCardLayoutUseCase.execute()
@ -147,27 +94,141 @@ struct AppearanceSettingsView: View {
} }
} }
} }
}
private func saveTagSortOrderSettings() {
Task {
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
settings.tagSortOrder = selectedTagSortOrder
try? await settingsRepository.saveSettings(settings)
// Update AppSettings to trigger UI updates struct CardLayoutPreview: View {
await MainActor.run { let layout: CardLayoutStyle
appSettings.settings?.tagSortOrder = selectedTagSortOrder let isSelected: Bool
NotificationCenter.default.post(name: .settingsChanged, object: nil) let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 12) {
// Visual Preview
switch layout {
case .compact:
// Compact: Small image on left, content on right
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue.opacity(0.6))
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 6)
.frame(maxWidth: .infinity)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 60)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.4))
.frame(height: 4)
.frame(maxWidth: 40)
} }
} }
.padding(8)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 50)
case .magazine:
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 40)
Text("Fixed 140px")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 65)
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
case .natural:
VStack(spacing: 3) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 38)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 35)
Text("Original ratio")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 75) // Höher als Magazine
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
}
VStack(alignment: .leading, spacing: 4) {
Text(layout.displayName)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(layout.description)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
} }
} }
#Preview { #Preview {
NavigationStack {
List {
AppearanceSettingsView() AppearanceSettingsView()
} .cardStyle()
.listStyle(.insetGrouped) .padding()
}
} }

View File

@ -8,10 +8,15 @@ struct CacheSettingsView: View {
@State private var showClearAlert: Bool = false @State private var showClearAlert: Bool = false
var body: some View { var body: some View {
Section { VStack(spacing: 20) {
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
.padding(.bottom, 4)
VStack(spacing: 12) {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size") Text("Current Cache Size")
.foregroundColor(.primary)
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max") Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -24,9 +29,12 @@ struct CacheSettingsView: View {
.foregroundColor(.blue) .foregroundColor(.blue)
} }
Divider()
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("Max Cache Size") Text("Max Cache Size")
.foregroundColor(.primary)
Spacer() Spacer()
Text("\(Int(maxCacheSize)) MB") Text("\(Int(maxCacheSize)) MB")
.font(.caption) .font(.caption)
@ -39,8 +47,11 @@ struct CacheSettingsView: View {
.onChange(of: maxCacheSize) { _, newValue in .onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue) updateMaxCacheSize(newValue)
} }
.accentColor(.blue)
} }
Divider()
Button(action: { Button(action: {
showClearAlert = true showClearAlert = true
}) { }) {
@ -48,9 +59,11 @@ struct CacheSettingsView: View {
if isClearing { if isClearing {
ProgressView() ProgressView()
.scaleEffect(0.8) .scaleEffect(0.8)
.frame(width: 24)
} else { } else {
Image(systemName: "trash") Image(systemName: "trash")
.foregroundColor(.red) .foregroundColor(.red)
.frame(width: 24)
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@ -65,8 +78,7 @@ struct CacheSettingsView: View {
} }
} }
.disabled(isClearing) .disabled(isClearing)
} header: { }
Text("Cache Settings")
} }
.onAppear { .onAppear {
updateCacheSize() updateCacheSize()
@ -130,8 +142,7 @@ struct CacheSettingsView: View {
} }
#Preview { #Preview {
List {
CacheSettingsView() CacheSettingsView()
} .cardStyle()
.listStyle(.insetGrouped) .padding()
} }

View File

@ -1,171 +0,0 @@
//
// CardLayoutSelectionView.swift
// readeck
//
// Created by Ilyas Hallak on 31.10.25.
//
import SwiftUI
struct CardLayoutSelectionView: View {
@Binding var selectedCardLayout: CardLayoutStyle
@Environment(\.dismiss) private var dismiss
let onSave: () -> Void
var body: some View {
List {
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
CardLayoutPreview(
layout: layout,
isSelected: selectedCardLayout == layout
) {
selectedCardLayout = layout
onSave()
dismiss()
}
}
}
.listStyle(.plain)
.navigationTitle("Card Layout")
.navigationBarTitleDisplayMode(.inline)
}
}
struct CardLayoutPreview: View {
let layout: CardLayoutStyle
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 12) {
// Visual Preview
switch layout {
case .compact:
// Compact: Small image on left, content on right
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue.opacity(0.6))
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 6)
.frame(maxWidth: .infinity)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 60)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.4))
.frame(height: 4)
.frame(maxWidth: 40)
}
}
.padding(8)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 50)
case .magazine:
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 40)
Text("Fixed 140px")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 65)
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
case .natural:
VStack(spacing: 3) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 38)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 35)
Text("Original ratio")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 75)
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
}
VStack(alignment: .leading, spacing: 4) {
Text(layout.displayName)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(layout.description)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
#Preview {
NavigationStack {
CardLayoutSelectionView(
selectedCardLayout: .constant(.magazine),
onSave: {}
)
}
}

View File

@ -1,444 +0,0 @@
//
// DebugLogViewer.swift
// readeck
//
// Created by Ilyas Hallak on 01.11.25.
//
import SwiftUI
struct DebugLogViewer: View {
@State private var entries: [LogEntry] = []
@State private var selectedLevel: LogLevel?
@State private var selectedCategory: LogCategory?
@State private var searchText = ""
@State private var showShareSheet = false
@State private var exportText = ""
@State private var autoScroll = true
@State private var showFilters = false
@StateObject private var logConfig = LogConfiguration.shared
private let logger = Logger.ui
var body: some View {
VStack(spacing: 0) {
// Logging Disabled Warning
if !logConfig.isLoggingEnabled {
loggingDisabledBanner
}
// Filter Bar
if showFilters {
filterBar
}
// Log List
if filteredEntries.isEmpty {
emptyState
} else {
ScrollViewReader { proxy in
List {
ForEach(filteredEntries) { entry in
LogEntryRow(entry: entry)
}
}
.listStyle(.plain)
.onChange(of: entries.count) { oldValue, newValue in
if autoScroll, let lastEntry = filteredEntries.last {
withAnimation {
proxy.scrollTo(lastEntry.id, anchor: .bottom)
}
}
}
}
}
}
.navigationTitle("Debug Logs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Menu {
Button {
showFilters.toggle()
} label: {
Label(
showFilters ? "Hide Filters" : "Show Filters",
systemImage: "line.3.horizontal.decrease.circle"
)
}
Button {
autoScroll.toggle()
} label: {
Label(
autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll",
systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle"
)
}
Divider()
Button {
Task {
await refreshLogs()
}
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
Button {
Task {
await exportLogs()
}
} label: {
Label("Export Logs", systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) {
Task {
await clearLogs()
}
} label: {
Label("Clear All Logs", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.searchable(text: $searchText, prompt: "Search logs")
.task {
await refreshLogs()
}
.sheet(isPresented: $showShareSheet) {
ActivityView(activityItems: [exportText])
}
}
@ViewBuilder
private var filterBar: some View {
VStack(spacing: 8) {
HStack {
Text("Filters")
.font(.headline)
Spacer()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// Level Filter
Menu {
Button("All Levels") {
selectedLevel = nil
}
Divider()
ForEach(LogLevel.allCases, id: \.self) { level in
Button {
selectedLevel = level
} label: {
HStack {
Text(levelName(for: level))
if selectedLevel == level {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack {
Image(systemName: "slider.horizontal.3")
Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level")
Image(systemName: "chevron.down")
.font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
.foregroundColor(selectedLevel != nil ? .accentColor : .primary)
.clipShape(Capsule())
}
// Category Filter
Menu {
Button("All Categories") {
selectedCategory = nil
}
Divider()
ForEach(LogCategory.allCases, id: \.self) { category in
Button {
selectedCategory = category
} label: {
HStack {
Text(category.rawValue)
if selectedCategory == category {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack {
Image(systemName: "tag")
Text(selectedCategory?.rawValue ?? "Category")
Image(systemName: "chevron.down")
.font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
.foregroundColor(selectedCategory != nil ? .accentColor : .primary)
.clipShape(Capsule())
}
// Clear Filters
if selectedLevel != nil || selectedCategory != nil {
Button {
selectedLevel = nil
selectedCategory = nil
} label: {
HStack {
Image(systemName: "xmark.circle.fill")
Text("Clear")
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.clipShape(Capsule())
}
}
}
.padding(.horizontal)
}
}
.padding(.vertical, 8)
.background(Color(.systemGroupedBackground))
}
@ViewBuilder
private var loggingDisabledBanner: some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 4) {
Text("Logging Disabled")
.font(.headline)
.foregroundColor(.primary)
Text("Enable logging in settings to capture new logs")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button {
logConfig.isLoggingEnabled = true
} label: {
Text("Enable")
.font(.subheadline)
.fontWeight(.semibold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.orange)
.foregroundColor(.white)
.clipShape(Capsule())
}
}
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal)
.padding(.top, 8)
}
@ViewBuilder
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 60))
.foregroundColor(.secondary)
Text("No Logs Found")
.font(.title2)
.fontWeight(.semibold)
if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil {
Text("Try adjusting your filters or search criteria")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
searchText = ""
selectedLevel = nil
selectedCategory = nil
} label: {
Text("Clear Filters")
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.clipShape(Capsule())
}
} else {
Text("Logs will appear here as they are generated")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
private var filteredEntries: [LogEntry] {
var filtered = entries
if let level = selectedLevel {
filtered = filtered.filter { $0.level == level }
}
if let category = selectedCategory {
filtered = filtered.filter { $0.category == category }
}
if !searchText.isEmpty {
filtered = filtered.filter {
$0.message.localizedCaseInsensitiveContains(searchText) ||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
$0.function.localizedCaseInsensitiveContains(searchText)
}
}
return filtered
}
private func refreshLogs() async {
entries = await LogStore.shared.getEntries()
}
private func clearLogs() async {
await LogStore.shared.clear()
await refreshLogs()
logger.info("Cleared all debug logs")
}
private func exportLogs() async {
exportText = await LogStore.shared.exportAsText()
showShareSheet = true
logger.info("Exported debug logs")
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Debug"
case 1: return "Info"
case 2: return "Notice"
case 3: return "Warning"
case 4: return "Error"
case 5: return "Critical"
default: return "Unknown"
}
}
}
// MARK: - Log Entry Row
struct LogEntryRow: View {
let entry: LogEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
// Level Badge
Text(levelName(for: entry.level))
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(levelColor(for: entry.level).opacity(0.2))
.foregroundColor(levelColor(for: entry.level))
.clipShape(Capsule())
// Category
Text(entry.category.rawValue)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
// Timestamp
Text(entry.formattedTimestamp)
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
// Message
Text(entry.message)
.font(.subheadline)
.foregroundColor(.primary)
// Source Location
HStack(spacing: 4) {
Image(systemName: "doc.text")
.font(.caption2)
Text("\(entry.fileName):\(entry.line)")
.font(.caption)
Text("")
.font(.caption)
Text(entry.function)
.font(.caption)
}
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "DEBUG"
case 1: return "INFO"
case 2: return "NOTICE"
case 3: return "WARN"
case 4: return "ERROR"
case 5: return "CRITICAL"
default: return "UNKNOWN"
}
}
private func levelColor(for level: LogLevel) -> Color {
switch level.rawValue {
case 0: return .blue
case 1: return .green
case 2: return .cyan
case 3: return .orange
case 4: return .red
case 5: return .purple
default: return .gray
}
}
}
// MARK: - Activity View (for Share Sheet)
struct ActivityView: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {
NavigationStack {
DebugLogViewer()
}
}

View File

@ -1,105 +0,0 @@
//
// FontSelectionView.swift
// readeck
//
// Created by Ilyas Hallak on 08.11.25.
//
import SwiftUI
struct FontSelectionView: View {
@State private var viewModel: FontSettingsViewModel
@Environment(\.dismiss) private var dismiss
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
self.viewModel = viewModel
}
var body: some View {
List {
// Preview Section
Section {
VStack(alignment: .leading, spacing: 12) {
Text("readeck Bookmark Title")
.font(viewModel.previewTitleFont)
.fontWeight(.semibold)
.lineLimit(2)
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
.font(viewModel.previewBodyFont)
.lineLimit(4)
Text("12 min • Today • example.com")
.font(viewModel.previewCaptionFont)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.08), radius: 8, x: 0, y: 2)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowBackground(Color.clear)
} header: {
Text("Preview")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.textCase(nil)
}
// Font Settings Section
Section {
Picker("Font family", selection: $viewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family)
}
}
.onChange(of: viewModel.selectedFontFamily) {
Task {
await viewModel.saveFontSettings()
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Font size")
.font(.subheadline)
.foregroundColor(.primary)
Picker("Font size", selection: $viewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
.pickerStyle(.segmented)
.onChange(of: viewModel.selectedFontSize) {
Task {
await viewModel.saveFontSettings()
}
}
}
.padding(.vertical, 4)
} header: {
Text("Font Settings")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.textCase(nil)
}
}
.listStyle(.insetGrouped)
.navigationTitle("Font")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.loadFontSettings()
}
}
}
#Preview {
NavigationStack {
FontSelectionView(viewModel: .init(
factory: MockUseCaseFactory()
))
}
}

View File

@ -15,35 +15,52 @@ struct FontSettingsView: View {
} }
var body: some View { var body: some View {
Group { VStack(spacing: 20) {
Section { SectionHeader(title: "Font Settings".localized, icon: "textformat")
.padding(.bottom, 4)
// Font Family Picker
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text("Font family")
.font(.headline)
Picker("Font family", selection: $viewModel.selectedFontFamily) { Picker("Font family", selection: $viewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family) Text(family.displayName).tag(family)
} }
} }
.pickerStyle(MenuPickerStyle())
.onChange(of: viewModel.selectedFontFamily) { .onChange(of: viewModel.selectedFontFamily) {
Task { Task {
await viewModel.saveFontSettings() await viewModel.saveFontSettings()
} }
} }
}
VStack(spacing: 16) {
// Font Size Picker
VStack(alignment: .leading, spacing: 8) {
Text("Font size")
.font(.headline)
Picker("Font size", selection: $viewModel.selectedFontSize) { Picker("Font size", selection: $viewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in ForEach(FontSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size) Text(size.displayName).tag(size)
} }
} }
.pickerStyle(.segmented) .pickerStyle(SegmentedPickerStyle())
.onChange(of: viewModel.selectedFontSize) { .onChange(of: viewModel.selectedFontSize) {
Task { Task {
await viewModel.saveFontSettings() await viewModel.saveFontSettings()
} }
} }
} header: {
Text("Font Settings")
} }
Section { // Font Preview
VStack(alignment: .leading, spacing: 8) {
Text("Preview")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("readeck Bookmark Title") Text("readeck Bookmark Title")
.font(viewModel.previewTitleFont) .font(viewModel.previewTitleFont)
@ -58,11 +75,16 @@ struct FontSettingsView: View {
.font(viewModel.previewCaptionFont) .font(viewModel.previewCaptionFont)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.vertical, 4) .padding(4)
} header: { .background(Color(.systemGray6))
Text("Preview") .clipShape(RoundedRectangle(cornerRadius: 8))
} }
} }
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
.task { .task {
await viewModel.loadFontSettings() await viewModel.loadFontSettings()
} }
@ -70,10 +92,7 @@ struct FontSettingsView: View {
} }
#Preview { #Preview {
List {
FontSettingsView(viewModel: .init( FontSettingsView(viewModel: .init(
factory: MockUseCaseFactory()) factory: MockUseCaseFactory())
) )
}
.listStyle(.insetGrouped)
} }

View File

@ -99,6 +99,48 @@ class FontSettingsViewModel {
} }
} }
// MARK: - Font Enums (moved from SettingsViewModel)
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -3,50 +3,63 @@ import SwiftUI
struct LegalPrivacySettingsView: View { struct LegalPrivacySettingsView: View {
@State private var showingPrivacyPolicy = false @State private var showingPrivacyPolicy = false
@State private var showingLegalNotice = false @State private var showingLegalNotice = false
@State private var showReleaseNotes = false
var body: some View { var body: some View {
Group { VStack(spacing: 20) {
Section { SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
Button(action: { .padding(.bottom, 4)
showReleaseNotes = true
}) {
HStack {
Text("What's New")
Spacer()
Text("Version \(VersionManager.shared.currentVersion)")
.font(.caption)
.foregroundColor(.secondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
VStack(spacing: 16) {
// Privacy Policy
Button(action: { Button(action: {
showingPrivacyPolicy = true showingPrivacyPolicy = true
}) { }) {
HStack { HStack {
Text(NSLocalizedString("Privacy Policy", comment: "")) Text(NSLocalizedString("Privacy Policy", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain)
// Legal Notice
Button(action: { Button(action: {
showingLegalNotice = true showingLegalNotice = true
}) { }) {
HStack { HStack {
Text(NSLocalizedString("Legal Notice", comment: "")) Text(NSLocalizedString("Legal Notice", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .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: { Button(action: {
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") { if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
UIApplication.shared.open(url) UIApplication.shared.open(url)
@ -54,13 +67,23 @@ struct LegalPrivacySettingsView: View {
}) { }) {
HStack { HStack {
Text(NSLocalizedString("Report an Issue", comment: "")) Text(NSLocalizedString("Report an Issue", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Image(systemName: "arrow.up.right") Image(systemName: "arrow.up.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain)
// Contact Support
Button(action: { Button(action: {
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") { if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
UIApplication.shared.open(url) UIApplication.shared.open(url)
@ -68,14 +91,22 @@ struct LegalPrivacySettingsView: View {
}) { }) {
HStack { HStack {
Text(NSLocalizedString("Contact Support", comment: "")) Text(NSLocalizedString("Contact Support", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Image(systemName: "arrow.up.right") Image(systemName: "arrow.up.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
} }
} header: {
Text("Legal, Privacy & Support")
} }
} }
.sheet(isPresented: $showingPrivacyPolicy) { .sheet(isPresented: $showingPrivacyPolicy) {
@ -84,15 +115,11 @@ struct LegalPrivacySettingsView: View {
.sheet(isPresented: $showingLegalNotice) { .sheet(isPresented: $showingLegalNotice) {
LegalNoticeView() LegalNoticeView()
} }
.sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView()
}
} }
} }
#Preview { #Preview {
List {
LegalPrivacySettingsView() LegalPrivacySettingsView()
} .cardStyle()
.listStyle(.insetGrouped) .padding()
} }

View File

@ -5,6 +5,8 @@
// Created by Ilyas Hallak on 16.08.25. // Created by Ilyas Hallak on 16.08.25.
// //
import SwiftUI import SwiftUI
import os import os
@ -13,70 +15,82 @@ struct LoggingConfigurationView: View {
private let logger = Logger.ui private let logger = Logger.ui
var body: some View { var body: some View {
List { NavigationView {
Section { Form {
Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled) Section(header: Text("Global Settings")) {
.tint(.green) VStack(alignment: .leading, spacing: 8) {
} header: { Text("Global Minimum Level")
Text("Logging Status") .font(.headline)
} footer: {
Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.")
}
if logConfig.isLoggingEnabled { Picker("Global Level", selection: $logConfig.globalMinLevel) {
Section { ForEach(LogLevel.allCases, id: \.self) { level in
NavigationLink {
GlobalLogLevelView(logConfig: logConfig)
} label: {
HStack { HStack {
Label("Global Log Level", systemImage: "slider.horizontal.3") Text(level.emoji)
Spacer() Text(level.rawValue == 0 ? "Debug" :
Text(levelName(for: logConfig.globalMinLevel)) level.rawValue == 1 ? "Info" :
.foregroundColor(.secondary) level.rawValue == 2 ? "Notice" :
level.rawValue == 3 ? "Warning" :
level.rawValue == 4 ? "Error" : "Critical")
} }
.tag(level)
}
}
.pickerStyle(SegmentedPickerStyle())
Text("Logs below this level will be filtered out globally")
.font(.caption)
.foregroundColor(.secondary)
} }
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs) Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps) Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation) Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
} header: {
Text("Global Settings")
} footer: {
Text("Logs below the global level will be filtered out globally")
}
} }
if logConfig.isLoggingEnabled { Section(header: Text("Category-specific Levels")) {
Section {
ForEach(LogCategory.allCases, id: \.self) { category in ForEach(LogCategory.allCases, id: \.self) { category in
NavigationLink { VStack(alignment: .leading, spacing: 8) {
CategoryLogLevelView(category: category, logConfig: logConfig)
} label: {
HStack { HStack {
Text(category.rawValue) Text(category.rawValue)
.font(.headline)
Spacer() Spacer()
Text(levelName(for: logConfig.getLevel(for: category))) Text(levelName(for: logConfig.getLevel(for: category)))
.font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Picker("Level for \(category.rawValue)", selection: Binding(
get: { logConfig.getLevel(for: category) },
set: { logConfig.setLevel($0, for: category) }
)) {
ForEach(LogLevel.allCases, id: \.self) { level in
HStack {
Text(level.emoji)
Text(levelName(for: level))
}
.tag(level)
} }
} }
} header: { .pickerStyle(SegmentedPickerStyle())
Text("Category-specific Levels") }
} footer: { .padding(.vertical, 4)
Text("Configure log levels for each category individually")
} }
} }
Section { Section(header: Text("Reset")) {
Button(role: .destructive) { Button("Reset to Defaults") {
resetToDefaults() resetToDefaults()
} label: {
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
} }
.foregroundColor(.orange)
}
Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) {
EmptyView()
} }
} }
.navigationTitle("Logging Configuration") .navigationTitle("Logging Configuration")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
}
.onAppear { .onAppear {
logger.debug("Opened logging configuration view") logger.debug("Opened logging configuration view")
} }
@ -97,10 +111,12 @@ struct LoggingConfigurationView: View {
private func resetToDefaults() { private func resetToDefaults() {
logger.info("Resetting logging configuration to defaults") logger.info("Resetting logging configuration to defaults")
// Reset all category levels (this will use globalMinLevel as fallback)
for category in LogCategory.allCases { for category in LogCategory.allCases {
logConfig.setLevel(.debug, for: category) logConfig.setLevel(.debug, for: category)
} }
// Reset global settings
logConfig.globalMinLevel = .debug logConfig.globalMinLevel = .debug
logConfig.showPerformanceLogs = true logConfig.showPerformanceLogs = true
logConfig.showTimestamps = true logConfig.showTimestamps = true
@ -110,123 +126,6 @@ struct LoggingConfigurationView: View {
} }
} }
// MARK: - Global Log Level View
struct GlobalLogLevelView: View {
@ObservedObject var logConfig: LogConfiguration
var body: some View {
List {
ForEach(LogLevel.allCases, id: \.self) { level in
Button {
logConfig.globalMinLevel = level
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(levelName(for: level))
.foregroundColor(.primary)
Text(levelDescription(for: level))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if logConfig.globalMinLevel == level {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
.navigationTitle("Global Log Level")
.navigationBarTitleDisplayMode(.inline)
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Debug"
case 1: return "Info"
case 2: return "Notice"
case 3: return "Warning"
case 4: return "Error"
case 5: return "Critical"
default: return "Unknown"
}
}
private func levelDescription(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Show all logs including debug information"
case 1: return "Show informational messages and above"
case 2: return "Show notable events and above"
case 3: return "Show warnings and errors only"
case 4: return "Show errors and critical issues only"
case 5: return "Show only critical issues"
default: return ""
}
}
}
// MARK: - Category Log Level View
struct CategoryLogLevelView: View {
let category: LogCategory
@ObservedObject var logConfig: LogConfiguration
var body: some View {
List {
ForEach(LogLevel.allCases, id: \.self) { level in
Button {
logConfig.setLevel(level, for: category)
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(levelName(for: level))
.foregroundColor(.primary)
Text(levelDescription(for: level))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if logConfig.getLevel(for: category) == level {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
.navigationTitle("\(category.rawValue) Logs")
.navigationBarTitleDisplayMode(.inline)
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Debug"
case 1: return "Info"
case 2: return "Notice"
case 3: return "Warning"
case 4: return "Error"
case 5: return "Critical"
default: return "Unknown"
}
}
private func levelDescription(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Show all logs including debug information"
case 1: return "Show informational messages and above"
case 2: return "Show notable events and above"
case 3: return "Show warnings and errors only"
case 4: return "Show errors and critical issues only"
case 5: return "Show only critical issues"
default: return ""
}
}
}
#Preview { #Preview {
NavigationStack {
LoggingConfigurationView() LoggingConfigurationView()
}
} }

View File

@ -1,35 +0,0 @@
import SwiftUI
import MarkdownUI
/// A custom view that renders Markdown content using the MarkdownUI library.
/// This view encapsulates the Markdown rendering logic, making it easy to swap
/// the underlying Markdown library if needed in the future.
struct MarkdownContentView: View {
let content: String
var body: some View {
Markdown(content)
.textSelection(.enabled)
}
}
#Preview {
ScrollView {
MarkdownContentView(content: """
# Heading 1
This is a paragraph with **bold** and *italic* text.
## Heading 2
- List item 1
- List item 2
- List item 3
### Heading 3
Another paragraph with [a link](https://example.com).
""")
.padding()
}
}

View File

@ -1,50 +0,0 @@
//
// ReadingSettingsView.swift
// readeck
//
// Created by Ilyas Hallak on 08.11.25.
//
import SwiftUI
struct ReadingSettingsView: View {
@State private var viewModel: SettingsGeneralViewModel
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
self.viewModel = viewModel
}
var body: some View {
Group {
Section {
VStack(alignment: .leading, spacing: 4) {
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
.onChange(of: viewModel.enableTTS) {
Task {
await viewModel.saveGeneralSettings()
}
}
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
} header: {
Text("Reading Settings")
}
}
.task {
await viewModel.loadGeneralSettings()
}
}
}
#Preview {
List {
ReadingSettingsView(viewModel: .init(
MockUseCaseFactory()
))
}
.listStyle(.insetGrouped)
}

View File

@ -1,43 +0,0 @@
import SwiftUI
struct ReleaseNotesView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if let markdownContent = loadReleaseNotes() {
MarkdownContentView(content: markdownContent)
.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() -> String? {
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
let markdownContent = try? String(contentsOf: url) else {
return nil
}
return markdownContent
}
}
#Preview {
ReleaseNotesView()
}

View File

@ -16,114 +16,133 @@ struct SettingsContainerView: View {
} }
var body: some View { var body: some View {
List { ScrollView {
AppearanceSettingsView() LazyVStack(spacing: 20) {
FontSettingsView()
.cardStyle()
ReadingSettingsView() AppearanceSettingsView()
.cardStyle()
CacheSettingsView() CacheSettingsView()
.cardStyle()
SyncSettingsView() SettingsGeneralView()
.cardStyle()
SettingsServerView() SettingsServerView()
.cardStyle()
LegalPrivacySettingsView() LegalPrivacySettingsView()
.cardStyle()
// Debug-only Logging Configuration // Debug-only Logging Configuration
#if DEBUG
if Bundle.main.isDebugBuild { if Bundle.main.isDebugBuild {
debugSettingsSection debugSettingsSection
} }
#endif
// App Info Section
appInfoSection
} }
.listStyle(.insetGrouped) .padding()
.background(Color(.systemGroupedBackground))
AppInfo()
Spacer()
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Settings") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} }
@ViewBuilder @ViewBuilder
private var debugSettingsSection: some View { private var debugSettingsSection: some View {
Section { VStack(alignment: .leading, spacing: 16) {
SettingsRowNavigationLink(
icon: "list.bullet.rectangle",
iconColor: .blue,
title: "Debug Logs",
subtitle: "View all debug messages"
) {
DebugLogViewer()
}
SettingsRowNavigationLink(
icon: "slider.horizontal.3",
iconColor: .purple,
title: "Logging Configuration",
subtitle: "Configure log levels and categories"
) {
LoggingConfigurationView()
}
} header: {
HStack { HStack {
Image(systemName: "ant.fill")
.foregroundColor(.orange)
Text("Debug Settings") Text("Debug Settings")
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Text("DEBUG BUILD") Text("DEBUG BUILD")
.font(.caption2) .font(.caption)
.padding(.horizontal, 6) .padding(.horizontal, 8)
.padding(.vertical, 2) .padding(.vertical, 4)
.background(Color.orange.opacity(0.2)) .background(Color.orange.opacity(0.2))
.foregroundColor(.orange) .foregroundColor(.orange)
.clipShape(Capsule()) .clipShape(Capsule())
} }
NavigationLink {
LoggingConfigurationView()
} label: {
HStack {
Image(systemName: "doc.text.magnifyingglass")
.foregroundColor(.blue)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text("Logging Configuration")
.foregroundColor(.primary)
Text("Configure log levels and categories")
.font(.caption)
.foregroundColor(.secondary)
} }
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.cardStyle()
} }
@ViewBuilder @ViewBuilder
private var appInfoSection: some View { func AppInfo() -> some View {
Section { VStack(spacing: 4) {
VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("Version \(appVersion)") Text("Version \(appVersion)")
.font(.caption) .font(.footnote)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: "person.crop.circle") Image(systemName: "person.crop.circle")
.font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
HStack(spacing: 4) {
Text("Developer:") Text("Developer:")
.font(.caption) .font(.footnote)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button("Ilyas Hallak") { Button("Ilyas Hallak") {
if let url = URL(string: "https://ilyashallak.de") { if let url = URL(string: "https://ilyashallak.de") {
UIApplication.shared.open(url) UIApplication.shared.open(url)
} }
} }
.font(.caption) .font(.footnote)
.foregroundColor(.blue)
.underline()
} }
}
HStack(spacing: 6) { HStack(spacing: 8) {
Image(systemName: "globe") Image(systemName: "globe")
.font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("From Bremen with 💚") Text("From Bremen with 💚")
.font(.caption) .font(.footnote)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity)
.listRowBackground(Color.clear) .padding(.top, 16)
.padding(.vertical, 8) .padding(.bottom, 4)
} .multilineTextAlignment(.center)
.opacity(0.7)
} }
} }
// Card Modifier für einheitlichen Look (kept for backwards compatibility with other views) // Card Modifier für einheitlichen Look
extension View { extension View {
func cardStyle() -> some View { func cardStyle() -> some View {
self self
@ -135,7 +154,5 @@ extension View {
} }
#Preview { #Preview {
NavigationStack {
SettingsContainerView() SettingsContainerView()
}
} }

View File

@ -9,83 +9,94 @@ 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
} }
var body: some View { var body: some View {
Group { VStack(spacing: 20) {
Section { SectionHeader(title: "General Settings".localized, icon: "gear")
Button(action: { .padding(.bottom, 4)
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)
}
}
VStack(alignment: .leading, spacing: 12) {
Text("General")
.font(.headline)
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS) Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
.toggleStyle(.switch)
.onChange(of: viewModel.enableTTS) { .onChange(of: viewModel.enableTTS) {
Task { Task {
await viewModel.saveGeneralSettings() await viewModel.saveGeneralSettings()
} }
} }
} header: {
Text("General")
} footer: {
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.") Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
.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
Section { // Sync Settings
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled) VStack(alignment: .leading, spacing: 12) {
if viewModel.autoSyncEnabled {
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
}
} header: {
Text("Sync Settings") Text("Sync Settings")
.font(.headline)
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
.toggleStyle(SwitchToggleStyle())
if viewModel.autoSyncEnabled {
HStack {
Text("Sync interval")
Spacer()
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
}
}
} }
Section { // Reading Settings
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) VStack(alignment: .leading, spacing: 12) {
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
} header: {
Text("Reading Settings") Text("Reading Settings")
.font(.headline)
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
.toggleStyle(SwitchToggleStyle())
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
.toggleStyle(SwitchToggleStyle())
} }
// Messages
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
Section {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
Text(successMessage) Text(successMessage)
.foregroundColor(.green) .foregroundColor(.green)
} .font(.caption)
} }
} }
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
Section {
HStack { HStack {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red) .foregroundColor(.red)
Text(errorMessage) Text(errorMessage)
.foregroundColor(.red) .foregroundColor(.red)
} .font(.caption)
} }
} }
#endif #endif
}
.sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView()
} }
.task { .task {
await viewModel.loadGeneralSettings() await viewModel.loadGeneralSettings()
@ -94,10 +105,7 @@ struct SettingsGeneralView: View {
} }
#Preview { #Preview {
List {
SettingsGeneralView(viewModel: .init( SettingsGeneralView(viewModel: .init(
MockUseCaseFactory() MockUseCaseFactory()
)) ))
}
.listStyle(.insetGrouped)
} }

View File

@ -11,33 +11,150 @@ struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel() @State private var viewModel = SettingsServerViewModel()
@State private var showingLogoutAlert = false @State private var showingLogoutAlert = false
var body: some View { init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) {
Section { self.viewModel = viewModel
SettingsRowValue( self.showingLogoutAlert = showingLogoutAlert
icon: "server.rack", }
title: "Server",
value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint var body: some View {
) VStack(spacing: 20) {
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
SettingsRowValue( .padding(.bottom, 4)
icon: "person.circle.fill",
title: "Username", Text(viewModel.isSetupMode ?
value: viewModel.username.isEmpty ? "Not set" : viewModel.username "Enter your Readeck server details to get started." :
) "Your current server connection and login credentials.")
.font(.body)
SettingsRowButton( .foregroundColor(.secondary)
icon: "rectangle.portrait.and.arrow.right", .multilineTextAlignment(.center)
iconColor: .red, .padding(.bottom, 8)
title: "Logout",
subtitle: nil, // Form
destructive: true VStack(spacing: 16) {
) { VStack(alignment: .leading, spacing: 6) {
showingLogoutAlert = true Text("Server Endpoint")
.font(.headline)
if viewModel.isSetupMode {
TextField("https://readeck.example.com", text: $viewModel.endpoint)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.endpoint) {
viewModel.clearMessages()
}
} else {
HStack {
Image(systemName: "server.rack")
.foregroundColor(.accentColor)
Text(viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint)
.foregroundColor(viewModel.endpoint.isEmpty ? .secondary : .primary)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Username")
.font(.headline)
if viewModel.isSetupMode {
TextField("Your Username", text: $viewModel.username)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.username) {
viewModel.clearMessages()
}
} else {
HStack {
Image(systemName: "person.circle.fill")
.foregroundColor(.accentColor)
Text(viewModel.username.isEmpty ? "Not set" : viewModel.username)
.foregroundColor(viewModel.username.isEmpty ? .secondary : .primary)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
if viewModel.isSetupMode {
VStack(alignment: .leading, spacing: 6) {
Text("Password")
.font(.headline)
SecureField("Your Password", text: $viewModel.password)
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.password) {
viewModel.clearMessages()
}
}
}
}
// Messages
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
}
}
if viewModel.isSetupMode {
VStack(spacing: 10) {
Button(action: {
Task {
await viewModel.saveServerSettings()
}
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canLogin || viewModel.isLoading)
}
} else {
Button(action: {
showingLogoutAlert = true
}) {
HStack(spacing: 6) {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.caption)
Text("Logout")
.font(.caption)
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(8)
}
} }
} header: {
Text("Server Connection")
} footer: {
Text("Your current server connection and login credentials.")
} }
.alert("Logout", isPresented: $showingLogoutAlert) { .alert("Logout", isPresented: $showingLogoutAlert) {
Button("Cancel", role: .cancel) { } Button("Cancel", role: .cancel) { }
@ -56,8 +173,7 @@ struct SettingsServerView: View {
} }
#Preview { #Preview {
List { SettingsServerView(viewModel: .init(
SettingsServerView() MockUseCaseFactory()
} ))
.listStyle(.insetGrouped)
} }

View File

@ -62,15 +62,8 @@ class SettingsServerViewModel {
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
do { do {
// Normalize endpoint before saving let user = try await loginUseCase.execute(endpoint: endpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
let normalizedEndpoint = normalizeEndpoint(endpoint) try await saveServerSettingsUseCase.execute(endpoint: endpoint, username: username, password: password, token: user.token)
let user = try await loginUseCase.execute(endpoint: normalizedEndpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
try await saveServerSettingsUseCase.execute(endpoint: normalizedEndpoint, username: username, password: password, token: user.token)
// Update local endpoint with normalized version
endpoint = normalizedEndpoint
isLoggedIn = true isLoggedIn = true
successMessage = "Server settings saved and successfully logged in." successMessage = "Server settings saved and successfully logged in."
try await SettingsRepository().saveHasFinishedSetup(true) try await SettingsRepository().saveHasFinishedSetup(true)
@ -81,51 +74,6 @@ class SettingsServerViewModel {
} }
} }
// MARK: - Endpoint Normalization
private func normalizeEndpoint(_ endpoint: String) -> String {
var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
// Remove query parameters
if let queryIndex = normalized.firstIndex(of: "?") {
normalized = String(normalized[..<queryIndex])
}
// Parse URL components
guard var urlComponents = URLComponents(string: normalized) else {
// If parsing fails, try adding https:// and parse again
normalized = "https://" + normalized
guard var urlComponents = URLComponents(string: normalized) else {
return normalized
}
return buildNormalizedURL(from: urlComponents)
}
return buildNormalizedURL(from: urlComponents)
}
private func buildNormalizedURL(from components: URLComponents) -> String {
var urlComponents = components
// Ensure scheme is http or https, default to https
if urlComponents.scheme == nil {
urlComponents.scheme = "https"
} else if urlComponents.scheme != "http" && urlComponents.scheme != "https" {
urlComponents.scheme = "https"
}
// Remove trailing slash from path if present
if urlComponents.path.hasSuffix("/") {
urlComponents.path = String(urlComponents.path.dropLast())
}
// Remove query parameters (already done above, but double check)
urlComponents.query = nil
urlComponents.fragment = nil
return urlComponents.string ?? components.string ?? ""
}
@MainActor @MainActor
func logout() async { func logout() async {
do { do {

View File

@ -1,64 +0,0 @@
//
// SyncSettingsView.swift
// readeck
//
// Created by Ilyas Hallak on 08.11.25.
//
import SwiftUI
struct SyncSettingsView: View {
@State private var viewModel: SettingsGeneralViewModel
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
self.viewModel = viewModel
}
var body: some View {
Group {
#if DEBUG
Section {
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
if viewModel.autoSyncEnabled {
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
}
} header: {
Text("Sync Settings")
}
if let successMessage = viewModel.successMessage {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
}
}
}
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
}
}
}
#endif
}
.task {
await viewModel.loadGeneralSettings()
}
}
}
#Preview {
List {
SyncSettingsView(viewModel: .init(
MockUseCaseFactory()
))
}
.listStyle(.insetGrouped)
}

View File

@ -8,11 +8,13 @@ extension Notification.Name {
// MARK: - Authentication // MARK: - Authentication
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse") static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
// MARK: - Network
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
// MARK: - UI Interactions // MARK: - UI Interactions
static let dismissKeyboard = Notification.Name("DismissKeyboard") static let dismissKeyboard = Notification.Name("DismissKeyboard")
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare") static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
// MARK: - User Preferences // MARK: - User Preferences
static let cardLayoutChanged = Notification.Name("cardLayoutChanged") static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
static let tagSortOrderChanged = Notification.Name("tagSortOrderChanged")
} }

Some files were not shown because too many files have changed in this diff Show More