Compare commits

...

25 Commits

Author SHA1 Message Date
aeb0bcad2e Merge branch 'main' into develop 2025-10-29 22:05:16 +01:00
f42653a92b bumped build version to 30
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 22:04:04 +01:00
fef1876297 fix: Improve markdown formatting in release notes view
Add custom AttributedString extension to properly format markdown with correct spacing and header styles. This fixes the compressed appearance of release notes by adding proper line breaks between sections and applying appropriate font sizes to headers.
2025-10-28 22:48:50 +01:00
907cc9220f perf: Optimize label loading for 1000+ labels
Major performance improvements to prevent crashes and lag when working with large label collections:

Main App:
- Switch to Core Data as primary source for labels (instant loading)
- Implement background API sync to keep labels up-to-date
- Add LazyVStack for efficient rendering of large label lists
- Use batch operations instead of individual queries (1 query vs 1000)
- Generate unique IDs for local labels to prevent duplicate warnings

Share Extension:
- Convert getTags() to async with background context
- Add saveTags() method with batch insert support
- Load labels from Core Data first, then sync with API
- Remove duplicate server reachability checks
- Reduce memory usage and prevent UI freezes

Technical Details:
- Labels now load instantly from local cache
- API sync happens in background without blocking UI
- Batch fetch operations for optimal database performance
- Proper error handling for offline scenarios
- Fixed duplicate ID warnings in ForEach loops

Fixes crashes and lag reported by users with 1000+ labels.
2025-10-26 21:24:12 +01:00
c629894611 feat: Show annotations button only when article contains annotations
Add conditional visibility for the annotations button in the toolbar based on whether the loaded article contains any rd-annotation tags.

Changes:
- Add hasAnnotations property to BookmarkDetailViewModel
- Check for <rd-annotation tags when processing article content
- Conditionally show/hide annotations button in BookmarkDetailView2
2025-10-26 21:20:08 +01:00
b77e4e3e9f refactor: Centralize annotation colors and improve color consistency
- Move AnnotationColor enum to Constants.swift for centralized color management
- Add hexColor property to provide hex values for JavaScript overlays
- Add cssColorWithOpacity method for flexible opacity control
- Update NativeWebView and WebView to use centralized color values
- Replace modal color picker with inline overlay for better UX
- Implement annotation creation directly from text selection
- Add API endpoint for creating annotations with selectors
2025-10-25 09:19:49 +02:00
1b9f79bccc fix: Use callJavaScript instead of evaluateJavaScript for WebPage
WebPage in iOS 26 uses callJavaScript method, not evaluateJavaScript.
2025-10-22 15:58:07 +02:00
d1157defbe fix: Resolve WebPage binding error in NativeWebView text selection
Capture webPage locally in Task to avoid @State binding issues when
calling evaluateJavaScript in async context.
2025-10-22 15:54:55 +02:00
a041300b4f feat: Add text selection support for iOS 26+ NativeWebView
Implement text selection detection in NativeWebView:
- Add onTextSelected callback parameter to NativeWebView
- Use JavaScript polling to detect text selections
- Calculate text offsets for precise annotation positioning
- Integrate color picker in BookmarkDetailView2 for iOS 26+
- Match feature parity with legacy WebView implementation

Text selection now works on both WebView implementations.
2025-10-22 15:35:56 +02:00
ec12815a51 feat: Add text selection and annotation creation UI
Implement interactive text annotation feature:
- Add text selection detection via JavaScript in WebView
- Create AnnotationColorPicker with 4 color options (yellow, green, blue, red)
- Integrate color picker sheet in bookmark detail views
- Calculate text offsets for precise annotation positioning
- Add onTextSelected callback for WebView component
- Prepare UI for future API integration

Users can now select text in articles and choose a highlight color.
API integration for persisting annotations will follow.
2025-10-22 15:30:34 +02:00
cf06a3147d feat: Add annotations support with color-coded highlighting
Add comprehensive annotations feature to bookmark detail views:
- Implement annotations list view with date formatting and state machine
- Add CSS-based highlighting for rd-annotation tags in WebView components
- Support Readeck color scheme (yellow, green, blue, red) for annotations
- Enable tap-to-scroll functionality to navigate to selected annotations
- Integrate annotations button in bookmark detail toolbar
- Add API endpoint and repository layer for fetching annotations
2025-10-22 15:25:55 +02:00
47f8f73664 fix: Improve markdown formatting in release notes view
Add custom AttributedString extension to properly format markdown with correct spacing and header styles. This fixes the compressed appearance of release notes by adding proper line breaks between sections and applying appropriate font sizes to headers.
2025-10-19 20:41:04 +02:00
d97e404cc7 fix: Prevent UICollectionView crash from concurrent bookmark list updates
Add isUpdating flag to prevent race conditions when updating the bookmark list.
This fixes crashes that occurred when returning to the app after adding a bookmark
via the share extension while the list was being updated.
2025-10-19 20:40:02 +02:00
6906509aea fix: Remove trailing slash from endpoint instead of adding it
Trailing slash is added elsewhere in the codebase, so here we remove it if present to avoid duplication
2025-10-19 19:40:25 +02:00
afe3d1e261 feat: Add endpoint normalization with validation rules
- Default to https if no scheme provided
- Only accept http and https schemes
- Add trailing slash to path automatically
- Remove query parameters and fragments
- Update endpoint field with normalized value after save
2025-10-19 19:37:35 +02:00
554e223bbc feat: Redesign server settings form with prompt parameters and quick input chips
- Remove redundant field labels, use prompt parameter instead
- Add QuickInputChip component for quick URL entry
- Add chips: http://, https://, 192.168., :8000
- Improve spacing and layout consistency
- Cleaner, more modern UI appearance
2025-10-19 19:26:40 +02:00
819eb4fc56 feat: Add helpful hint text for server endpoint field
- Clarify HTTP/HTTPS support
- Note HTTP restriction to local networks
- Mention optional port configuration
- Indicate trailing slash not required
2025-10-19 19:17:30 +02:00
6385d10317 fix: Set gray tint color for server endpoint TextField placeholder 2025-10-19 19:16:12 +02:00
31ed3fc0e1 fix: Use @State instead of @StateObject for @Observable AppViewModel
- Replace @StateObject with @State for @Observable conformance
- Remove unnecessary Task wrapper in init
- Call loadSetupStatus() synchronously since it's already @MainActor
2025-10-19 19:06:35 +02:00
04de2c20d4 refactor: Use @Observable and inject factory in AppViewModel
- Replace ObservableObject with @Observable macro
- Inject UseCaseFactory instead of individual use cases
- Use factory.makeCheckServerReachabilityUseCase() on demand
- Use factory.makeLogoutUseCase() for 401 handling
2025-10-19 19:01:54 +02:00
fde1140f24 refactor: Check server reachability on app resume instead of app start
- Move server check from init to onAppResume() in AppViewModel
- Add scenePhase observer in readeckApp
- Check only when app becomes active (.active phase)
- Respects 30s cache - won't call API if recently checked
2025-10-19 11:08:13 +02:00
e5334d456d refactor: Remove NWPathMonitor auto-sync, keep only on-demand server checks
- Delete NetworkConnectivity.swift with problematic NWPathMonitor
- Remove serverDidBecomeAvailable notification
- Remove unused startAutoSync from OfflineSyncManager
- Server check now only on app start via AppViewModel
2025-10-19 10:47:19 +02:00
1957995a9e refactor: Update NetworkConnectivity to use CheckServerReachabilityUseCase 2025-10-19 10:45:21 +02:00
bf3ee7a1d7 fix: Add MockCheckServerReachabilityUseCase implementation 2025-10-19 10:32:44 +02:00
ef8ebd6f00 refactor: Optimize server connectivity with Clean Architecture
- Replace ServerConnectivity with CheckServerReachabilityUseCase
- Add InfoApiClient for /api/info endpoint
- Implement ServerInfoRepository with 30s cache TTL and 5s rate limiting
- Update ShareBookmarkViewModel to use ShareExtensionServerCheck manager
- Add server reachability check in AppViewModel on app start
- Update OfflineSyncManager to use new UseCase
- Extend SimpleAPI with checkServerReachability for Share Extension
2025-10-19 09:43:47 +02:00
50 changed files with 2070 additions and 360 deletions

View File

@ -49,19 +49,54 @@ class OfflineBookmarkManager: @unchecked Sendable {
}
}
func getTags() -> [String] {
func getTags() async -> [String] {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
return try context.safePerform { [weak self] in
guard let self = self else { return [] }
return try await backgroundContext.perform {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
let tagEntities = try self.context.fetch(fetchRequest)
return tagEntities.compactMap { $0.name }.sorted()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
let tagEntities = try backgroundContext.fetch(fetchRequest)
return tagEntities.compactMap { $0.name }
}
} catch {
print("Failed to fetch tags: \(error)")
return []
}
}
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
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)")
}
}
}

View File

@ -1,62 +0,0 @@
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

@ -13,8 +13,9 @@ class ShareBookmarkViewModel: ObservableObject {
@Published var searchText: String = ""
@Published var isServerReachable: Bool = true
let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel
private let serverCheck = ShareExtensionServerCheck.shared
var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) }
@ -50,17 +51,9 @@ class ShareBookmarkViewModel: ObservableObject {
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() {
logger.debug("Starting to extract shared content")
guard let extensionContext = extensionContext else {
@ -131,30 +124,43 @@ class ShareBookmarkViewModel: ObservableObject {
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
logger.debug("Starting to load labels")
Task {
let serverReachable = ServerConnectivity.isServerReachableSync()
// 1. First, load from Core Data (instant response)
let localTags = await OfflineBookmarkManager.shared.getTags()
let localLabels = localTags.enumerated().map { index, tagName in
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
}
await MainActor.run {
self.labels = localLabels
self.logger.info("Loaded \(localLabels.count) labels from local cache")
}
// 2. Then check server and sync in background
let serverReachable = await serverCheck.checkServerReachability()
await MainActor.run {
self.isServerReachable = serverReachable
}
logger.debug("Server reachable for labels: \(serverReachable)")
if serverReachable {
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
self?.statusMessage = (message, error, error ? "" : "")
if error {
self?.logger.error("Failed to sync labels from API: \(message)")
}
} ?? []
// Save new labels to Core Data
let tagNames = loaded.map { $0.name }
await OfflineBookmarkManager.shared.saveTags(tagNames)
let sorted = loaded.sorted { $0.count > $1.count }
await MainActor.run {
self.labels = Array(sorted)
self.logger.info("Loaded \(loaded.count) labels from API")
self.logger.info("Synced \(loaded.count) labels from API and updated cache")
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()
}
measurement.end()
}
}
}
@ -168,14 +174,14 @@ class ShareBookmarkViewModel: ObservableObject {
}
isSaving = true
logger.debug("Set saving state to true")
// Check server connectivity
let serverReachable = ServerConnectivity.isServerReachableSync()
logger.debug("Server connectivity for save: \(serverReachable)")
if serverReachable {
// Online - try to save via API
logger.info("Attempting to save bookmark via API")
Task {
Task {
let serverReachable = await serverCheck.checkServerReachability()
logger.debug("Server connectivity for save: \(serverReachable)")
if serverReachable {
// Online - try to save via API
logger.info("Attempting to save bookmark via API")
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?.statusMessage = (message, error, error ? "" : "")
@ -189,28 +195,28 @@ class ShareBookmarkViewModel: ObservableObject {
self?.logger.error("Failed to save bookmark via API: \(message)")
}
}
}
} else {
// Server not reachable - save locally
logger.info("Server not reachable, attempting local save")
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
url: url,
title: title,
tags: Array(selectedLabels)
)
logger.info("Local save result: \(success)")
DispatchQueue.main.async {
self.isSaving = false
if success {
self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.completeExtensionRequest()
} else {
// Server not reachable - save locally
logger.info("Server not reachable, attempting local save")
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
url: url,
title: title,
tags: Array(selectedLabels)
)
logger.info("Local save result: \(success)")
DispatchQueue.main.async {
self.isSaving = false
if success {
self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.completeExtensionRequest()
}
} else {
self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "")
}
} else {
self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "")
}
}
}

View File

@ -0,0 +1,41 @@
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

@ -2,7 +2,40 @@ import Foundation
class SimpleAPI {
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
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
logger.info("Adding bookmark: \(url)")

View File

@ -1,5 +1,17 @@
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 let labels: [String]?
public let title: String?
@ -33,4 +45,3 @@ public struct BookmarkLabelDto: Codable, Identifiable {
self.href = href
}
}

View File

@ -437,7 +437,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -470,7 +470,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -625,7 +625,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -669,7 +669,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;

View File

@ -18,6 +18,8 @@ protocol PAPI {
func deleteBookmark(id: String) async throws
func searchBookmarks(search: String) async throws -> BookmarksPageDto
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
}
class API: PAPI {
@ -435,15 +437,55 @@ class API: PAPI {
logger.debug("Fetching bookmark labels")
let endpoint = "/api/bookmarks/labels"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let result = try await makeJSONRequest(
endpoint: endpoint,
responseType: [BookmarkLabelDto].self
)
logger.info("Successfully fetched \(result.count) bookmark labels")
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
}
}
enum HTTPMethod: String {

View File

@ -0,0 +1,21 @@
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,55 @@
//
// 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

@ -0,0 +1,24 @@
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
)
}
}
}

View File

@ -11,34 +11,66 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
}
func getLabels() async throws -> [BookmarkLabel] {
let dtos = try await api.getBookmarkLabels()
try? await saveLabels(dtos)
return dtos.map { $0.toDomain() }
// First, load from Core Data (instant response)
let cachedLabels = try await loadLabelsFromCoreData()
// 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: "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: 0,
href: name
)
}
}
}
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform { [weak self] in
guard let self = self else { return }
try await backgroundContext.perform {
// Batch fetch all existing label names (much faster than individual queries)
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 labels
var insertCount = 0
for dto in dtos {
if !self.tagExists(name: dto.name, in: backgroundContext) {
if !existingNames.contains(dto.name) {
dto.toEntity(context: backgroundContext)
insertCount += 1
}
}
try backgroundContext.save()
}
}
private func tagExists(name: String, in context: NSManagedObjectContext) -> Bool {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
do {
let count = try context.count(for: fetchRequest)
return count > 0
} catch {
return false
// Only save if there are new labels
if insertCount > 0 {
try backgroundContext.save()
}
}
}
}

View File

@ -4,22 +4,25 @@ import SwiftUI
class OfflineSyncManager: ObservableObject, @unchecked Sendable {
static let shared = OfflineSyncManager()
@Published var isSyncing = false
@Published var syncStatus: String?
private let coreDataManager = CoreDataManager.shared
private let api: PAPI
init(api: PAPI = API()) {
private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase
init(api: PAPI = API(),
checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) {
self.api = api
self.checkServerReachabilityUseCase = checkServerReachabilityUseCase
}
// MARK: - Sync Methods
func syncOfflineBookmarks() async {
// First check if server is reachable
guard await ServerConnectivity.isServerReachable() else {
guard await checkServerReachabilityUseCase.execute() else {
await MainActor.run {
isSyncing = false
syncStatus = "Server not reachable. Cannot sync."
@ -121,22 +124,4 @@ 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

@ -0,0 +1,114 @@
//
// 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,92 +0,0 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,21 @@
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

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

View File

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

View File

@ -0,0 +1,28 @@
//
// 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

@ -0,0 +1,17 @@
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

@ -4,6 +4,39 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi
**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
### 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 from local cache, then sync in background
- Optimized label management to prevent crashes and lag
- Share Extension now loads labels without delay
- Reduced memory usage when working with large label collections
- Better offline support - labels always available even without internet
### Fixes & Improvements
- Centralized color management for consistent appearance
- Improved annotation creation workflow
- Better text selection handling in article view
- Implemented lazy loading for label lists
- Switched to Core Data as primary source for labels
- Batch operations for faster database queries
- Background sync to keep labels up-to-date without blocking the UI
- Fixed duplicate ID warnings in label lists
---
## Version 1.1
There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development.

View File

@ -8,19 +8,20 @@
import Foundation
import SwiftUI
class AppViewModel: ObservableObject {
@MainActor
@Observable
class AppViewModel {
private let settingsRepository = SettingsRepository()
private let logoutUseCase: LogoutUseCase
@Published var hasFinishedSetup: Bool = true
init(logoutUseCase: LogoutUseCase = LogoutUseCase()) {
self.logoutUseCase = logoutUseCase
private let factory: UseCaseFactory
var hasFinishedSetup: Bool = true
var isServerReachable: Bool = false
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.factory = factory
setupNotificationObservers()
Task {
await loadSetupStatus()
}
loadSetupStatus()
}
private func setupNotificationObservers() {
@ -29,7 +30,7 @@ class AppViewModel: ObservableObject {
object: nil,
queue: .main
) { [weak self] _ in
Task {
Task { @MainActor in
await self?.handleUnauthorizedResponse()
}
}
@ -39,19 +40,17 @@ class AppViewModel: ObservableObject {
object: nil,
queue: .main
) { [weak self] _ in
self?.loadSetupStatus()
Task { @MainActor in
self?.loadSetupStatus()
}
}
}
@MainActor
private func handleUnauthorizedResponse() async {
print("AppViewModel: Handling 401 Unauthorized - logging out user")
do {
// Führe den Logout durch
try await logoutUseCase.execute()
// Update UI state
try await factory.makeLogoutUseCase().execute()
loadSetupStatus()
print("AppViewModel: User successfully logged out due to 401 error")
@ -60,11 +59,18 @@ class AppViewModel: ObservableObject {
}
}
@MainActor
private func loadSetupStatus() {
hasFinishedSetup = settingsRepository.hasFinishedSetup
}
func onAppResume() async {
await checkServerReachability()
}
private func checkServerReachability() async {
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
}
deinit {
NotificationCenter.default.removeObserver(self)
}

View File

@ -0,0 +1,45 @@
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

@ -0,0 +1,63 @@
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

@ -0,0 +1,120 @@
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)
}
case .error:
EmptyView()
}
}
.navigationTitle("Annotations")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.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

@ -0,0 +1,29 @@
import Foundation
@Observable
class AnnotationsListViewModel {
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
var annotations: [Annotation] = []
var isLoading = false
var errorMessage: String?
var showErrorAlert = false
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
}
@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
}
}
}

View File

@ -29,18 +29,19 @@ struct BookmarkDetailLegacyView: View {
@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()) {
@ -86,6 +87,20 @@ struct BookmarkDetailLegacyView: View {
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
)
}
}
)
.frame(height: webViewHeight)
@ -220,6 +235,12 @@ struct BookmarkDetailLegacyView: View {
Image(systemName: "tag")
}
Button(action: {
showingAnnotationsSheet = true
}) {
Image(systemName: "pencil.line")
}
Button(action: {
showingFontSettings = true
}) {
@ -252,6 +273,11 @@ struct BookmarkDetailLegacyView: View {
.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)
}
@ -271,9 +297,20 @@ struct BookmarkDetailLegacyView: View {
}
}
}
.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)

View File

@ -14,6 +14,7 @@ struct BookmarkDetailView2: View {
@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
@ -50,6 +51,11 @@ struct BookmarkDetailView2: View {
.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)
}
@ -67,9 +73,19 @@ struct BookmarkDetailView2: View {
}
}
}
.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)
@ -254,6 +270,14 @@ struct BookmarkDetailView2: View {
Image(systemName: "tag")
}
if viewModel.hasAnnotations {
Button(action: {
showingAnnotationsSheet = true
}) {
Image(systemName: "pencil.line")
}
}
Button(action: {
showingFontSettings = true
}) {
@ -437,6 +461,20 @@ struct BookmarkDetailView2: View {
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
)
}
}
)
.frame(height: webViewHeight)

View File

@ -8,7 +8,8 @@ class BookmarkDetailViewModel {
private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
private let api: PAPI
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
var articleParagraphs: [String] = []
@ -18,7 +19,9 @@ class BookmarkDetailViewModel {
var errorMessage: String?
var settings: Settings?
var readProgress: Int = 0
var selectedAnnotationId: String?
var hasAnnotations: Bool = false
private var factory: UseCaseFactory?
private var cancellables = Set<AnyCancellable>()
private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>()
@ -28,8 +31,9 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.api = API()
self.factory = factory
readProgressSubject
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink { [weak self] (id, progress, anchor) in
@ -67,23 +71,26 @@ class BookmarkDetailViewModel {
@MainActor
func loadArticleContent(id: String) async {
isLoadingArticle = true
do {
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
processArticleContent()
} catch {
errorMessage = "Error loading article"
}
isLoadingArticle = false
}
private func processArticleContent() {
let paragraphs = articleContent
.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
articleParagraphs = paragraphs
// Check if article contains annotations
hasAnnotations = articleContent.contains("<rd-annotation")
}
@MainActor
@ -137,4 +144,22 @@ class BookmarkDetailViewModel {
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
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

@ -22,9 +22,12 @@ class BookmarksViewModel {
var showingAddBookmarkFromShare = false
var shareURL = ""
var shareTitle = ""
// Undo delete functionality
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
// Prevent concurrent updates
private var isUpdating = false
private var cancellables = Set<AnyCancellable>()
@ -104,15 +107,19 @@ class BookmarksViewModel {
@MainActor
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
guard !isUpdating else { return }
isUpdating = true
defer { isUpdating = false }
isLoading = true
errorMessage = nil
currentState = state
currentType = type
currentTag = tag
offset = 0
hasMoreData = true
do {
let newBookmarks = try await getBooksmarksUseCase.execute(
state: state,
@ -142,18 +149,20 @@ class BookmarksViewModel {
}
// Don't clear bookmarks on error - keep existing data visible
}
isLoading = false
isInitialLoading = false
}
@MainActor
func loadMoreBookmarks() async {
guard !isLoading && hasMoreData else { return } // prevent multiple loads
guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads
isUpdating = true
defer { isUpdating = false }
isLoading = true
errorMessage = nil
do {
offset += limit // inc. offset
let newBookmarks = try await getBooksmarksUseCase.execute(
@ -181,7 +190,7 @@ class BookmarksViewModel {
errorMessage = "Error loading more bookmarks"
}
}
isLoading = false
}

View File

@ -10,7 +10,53 @@
//
import Foundation
import SwiftUI
struct Constants {
// Empty for now - can be used for other constants in the future
// Annotation colors
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

@ -11,7 +11,9 @@ struct NativeWebView: View {
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
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
@ -20,6 +22,7 @@ struct NativeWebView: View {
.scrollDisabled(true) // Disable internal scrolling
.onAppear {
loadStyledContent()
setupAnnotationMessageHandler()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
@ -27,6 +30,9 @@ struct NativeWebView: View {
.onChange(of: colorScheme) { _, _ in
loadStyledContent()
}
.onChange(of: selectedAnnotationId) { _, _ in
loadStyledContent()
}
.onChange(of: webPage.isLoading) { _, isLoading in
if !isLoading {
// Update height when content finishes loading
@ -38,6 +44,44 @@ struct NativeWebView: View {
}
}
}
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 updateContentHeightWithJS() async {
var lastHeight: CGFloat = 0
@ -197,6 +241,49 @@ struct NativeWebView: View {
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>
@ -242,6 +329,12 @@ struct NativeWebView: View {
}
scheduleHeightCheck();
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</script>
</body>
</html>
@ -273,6 +366,280 @@ struct NativeWebView: View {
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');
setTimeout(() => {
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
} else {
setTimeout(scrollToAnnotation, 300);
}
"""
}
}
// MARK: - Hybrid WebView (Not Currently Used)

View File

@ -214,7 +214,7 @@ struct TagManagementView: View {
@ViewBuilder
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 8) {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(chunkedLabels, id: \.self) { rowLabels in
HStack(alignment: .top, spacing: 8) {
ForEach(rowLabels, id: \.id) { label in

View File

@ -6,6 +6,8 @@ struct WebView: UIViewRepresentable {
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
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView {
@ -28,8 +30,11 @@ struct WebView: UIViewRepresentable {
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.webView = webView
return webView
}
@ -37,6 +42,7 @@ struct WebView: UIViewRepresentable {
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
let isDarkMode = colorScheme == .dark
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
@ -235,6 +241,49 @@ struct WebView: UIViewRepresentable {
--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>
</head>
<body>
@ -264,6 +313,12 @@ struct WebView: UIViewRepresentable {
document.querySelectorAll('img').forEach(img => {
img.addEventListener('load', debouncedHeightUpdate);
});
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</script>
</body>
</html>
@ -276,6 +331,7 @@ struct WebView: UIViewRepresentable {
webView.navigationDelegate = nil
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
webView.loadHTMLString("", baseURL: nil)
coordinator.cleanup()
}
@ -305,12 +361,295 @@ struct WebView: UIViewRepresentable {
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');
setTimeout(() => {
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 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 {
// Callbacks
var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
// WebView reference
weak var webView: WKWebView?
// Height management
var lastHeight: CGFloat = 0
@ -352,6 +691,17 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
self.handleScrollProgress(progress: progress)
}
}
if message.name == "annotationCreated", let body = message.body as? [String: Any],
let color = body["color"] as? String,
let text = body["text"] as? String,
let startOffset = body["startOffset"] as? Int,
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)
}
}
}
private func handleHeightUpdate(height: CGFloat) {
@ -419,13 +769,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
func cleanup() {
guard !isCleanedUp else { return }
isCleanedUp = true
scrollEndTimer?.invalidate()
scrollEndTimer = nil
heightUpdateTimer?.invalidate()
heightUpdateTimer = nil
onHeightChange = nil
onScroll = nil
onAnnotationCreated = nil
}
}

View File

@ -20,6 +20,8 @@ protocol UseCaseFactory {
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
}
@ -30,9 +32,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
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()
private init() {}
func makeLoginUseCase() -> PLoginUseCase {
@ -112,4 +117,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
}
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase {
return CheckServerReachabilityUseCase(repository: serverInfoRepository)
}
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
}
}

View File

@ -9,6 +9,10 @@ import Foundation
import Combine
class MockUseCaseFactory: UseCaseFactory {
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
MockCheckServerReachabilityUseCase()
}
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
MockOfflineBookmarkSyncUseCase()
}
@ -84,6 +88,10 @@ class MockUseCaseFactory: UseCaseFactory {
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
MockSaveCardLayoutUseCase()
}
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
MockGetBookmarkAnnotationsUseCase()
}
}
@ -224,6 +232,24 @@ 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: "")
]
}
}
extension Bookmark {
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)

View File

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

View File

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

View File

@ -12,7 +12,7 @@ struct PhoneTabView: View {
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
@State private var selectedTab: SidebarTab = .unread
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
// Navigation paths for each tab
@State private var allPath = NavigationPath()
@ -149,11 +149,11 @@ struct PhoneTabView: View {
.padding()
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
List(bookmarks) { bookmark in
// Hidden NavigationLink to remove disclosure indicator
// To restore: uncomment block below and remove ZStack
ZStack {
// Hidden NavigationLink to remove disclosure indicator
NavigationLink {
BookmarkDetailView(bookmarkId: bookmark.id)
BookmarkDetailView(bookmarkId: bookmark.id)
} label: {
EmptyView()
}
@ -234,11 +234,11 @@ struct PhoneTabView: View {
case .all:
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .unread:
BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil))
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .favorite:
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .archived:
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .search:
EmptyView() // search is directly implemented
case .settings:

View File

@ -1,5 +1,46 @@
import SwiftUI
extension AttributedString {
init(styledMarkdown markdownString: String) throws {
var output = try AttributedString(
markdown: markdownString,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
),
baseURL: nil
)
for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
guard let intentBlock = intentBlock else { continue }
for intent in intentBlock.components {
switch intent.kind {
case .header(level: let level):
switch level {
case 1:
output[intentRange].font = .system(.title).bold()
case 2:
output[intentRange].font = .system(.title2).bold()
case 3:
output[intentRange].font = .system(.title3).bold()
default:
break
}
default:
break
}
}
if intentRange.lowerBound != output.startIndex {
output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound)
}
}
self = output
}
}
struct ReleaseNotesView: View {
@Environment(\.dismiss) private var dismiss
@ -33,10 +74,7 @@ struct ReleaseNotesView: View {
private func loadReleaseNotes() -> AttributedString? {
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
let markdownContent = try? String(contentsOf: url),
let attributedString = try? AttributedString(
markdown: markdownContent,
options: .init(interpretedSyntax: .full)
) else {
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
return nil
}
return attributedString

View File

@ -10,17 +10,16 @@ import SwiftUI
struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel()
@State private var showingLogoutAlert = false
init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) {
self.viewModel = viewModel
init(showingLogoutAlert: Bool = false) {
self.showingLogoutAlert = showingLogoutAlert
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
.padding(.bottom, 4)
Text(viewModel.isSetupMode ?
"Enter your Readeck server details to get started." :
"Your current server connection and login credentials.")
@ -28,14 +27,15 @@ struct SettingsServerView: View {
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
// Form
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Server Endpoint")
.font(.headline)
// Server Endpoint
VStack(alignment: .leading, spacing: 8) {
if viewModel.isSetupMode {
TextField("https://readeck.example.com", text: $viewModel.endpoint)
TextField("",
text: $viewModel.endpoint,
prompt: Text("Server Endpoint").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
@ -43,6 +43,42 @@ struct SettingsServerView: View {
.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)
} else {
HStack {
Image(systemName: "server.rack")
@ -55,11 +91,13 @@ struct SettingsServerView: View {
.padding(.vertical, 8)
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Username")
.font(.headline)
// Username
VStack(alignment: .leading, spacing: 8) {
if viewModel.isSetupMode {
TextField("Your Username", text: $viewModel.username)
TextField("",
text: $viewModel.username,
prompt: Text("Username").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
@ -78,12 +116,13 @@ struct SettingsServerView: View {
.padding(.vertical, 8)
}
}
// Password
if viewModel.isSetupMode {
VStack(alignment: .leading, spacing: 6) {
Text("Password")
.font(.headline)
SecureField("Your Password", text: $viewModel.password)
VStack(alignment: .leading, spacing: 8) {
SecureField("",
text: $viewModel.password,
prompt: Text("Password").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.password) {
viewModel.clearMessages()
@ -91,7 +130,7 @@ struct SettingsServerView: View {
}
}
}
// Messages
if let errorMessage = viewModel.errorMessage {
HStack {
@ -102,7 +141,7 @@ struct SettingsServerView: View {
.font(.caption)
}
}
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
@ -112,7 +151,7 @@ struct SettingsServerView: View {
.font(.caption)
}
}
if viewModel.isSetupMode {
VStack(spacing: 10) {
Button(action: {
@ -135,7 +174,7 @@ struct SettingsServerView: View {
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canLogin || viewModel.isLoading)
.disabled(!viewModel.canLogin || viewModel.isLoading)
}
} else {
Button(action: {
@ -172,8 +211,22 @@ struct SettingsServerView: View {
}
}
#Preview {
SettingsServerView(viewModel: .init(
MockUseCaseFactory()
))
// 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)
}
}
}

View File

@ -62,8 +62,15 @@ class SettingsServerViewModel {
isLoading = true
defer { isLoading = false }
do {
let user = try await loginUseCase.execute(endpoint: endpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
try await saveServerSettingsUseCase.execute(endpoint: endpoint, username: username, password: password, token: user.token)
// Normalize endpoint before saving
let normalizedEndpoint = normalizeEndpoint(endpoint)
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
successMessage = "Server settings saved and successfully logged in."
try await SettingsRepository().saveHasFinishedSetup(true)
@ -73,6 +80,51 @@ class SettingsServerViewModel {
isLoggedIn = false
}
}
// 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
func logout() async {

View File

@ -7,10 +7,7 @@ extension Notification.Name {
// MARK: - Authentication
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
// MARK: - Network
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
// MARK: - UI Interactions
static let dismissKeyboard = Notification.Name("DismissKeyboard")
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")

View File

@ -10,8 +10,9 @@ import netfox
@main
struct readeckApp: App {
@StateObject private var appViewModel = AppViewModel()
@State private var appViewModel = AppViewModel()
@StateObject private var appSettings = AppSettings()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
@ -29,8 +30,6 @@ struct readeckApp: App {
#if DEBUG
NFX.sharedInstance().start()
#endif
// Initialize server connectivity monitoring
_ = ServerConnectivity.shared
Task {
await loadAppSettings()
}
@ -40,6 +39,13 @@ struct readeckApp: App {
await loadAppSettings()
}
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .active {
Task {
await appViewModel.onAppResume()
}
}
}
}
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB