feat: Add annotations, cache management, and offline feature improvements

- Add annotation creation to API and repository layer (AnnotationsRepository)
- Add DtoMapper for AnnotationDto to domain model conversion
- Extend PAnnotationsRepository protocol with createAnnotation method
- Add cache management to SettingsRepository (getCacheSize, getMaxCacheSize, updateMaxCacheSize, clearCache)
- Extend PSettingsRepository protocol with cache settings methods
- Use localized Highlight label in annotation overlay JavaScript for WebView and NativeWebView
- Improve API error handling with detailed logging for HTTP errors and response data
- Add LocalizedError extension for APIError with human-readable descriptions
- Update localization strings for German and English (Highlight, Synchronization, VPN warning)
- Update RELEASE_NOTES.md with version 2.0.0 offline reading feature details
This commit is contained in:
Ilyas Hallak 2025-12-01 22:01:23 +01:00
parent 4fd55ef5d0
commit 8dc5f3000a
11 changed files with 153 additions and 8 deletions

View File

@ -153,21 +153,26 @@ class API: PAPI {
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for \(endpoint)")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
logger.error("Server error for \(endpoint): HTTP \(httpResponse.statusCode)")
logger.error("Response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")")
handleUnauthorizedResponse(httpResponse.statusCode)
throw APIError.serverError(httpResponse.statusCode)
}
// Als String dekodieren statt als JSON
guard let string = String(data: data, encoding: .utf8) else {
logger.error("Unable to decode response as UTF-8 string for \(endpoint)")
logger.error("Data size: \(data.count) bytes")
throw APIError.invalidResponse
}
return string
}
@ -540,3 +545,16 @@ enum APIError: Error {
case invalidResponse
case serverError(Int)
}
extension APIError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidResponse:
return "Invalid server response"
case .serverError(let statusCode):
return "Server error: HTTP \(statusCode)"
}
}
}

View File

@ -0,0 +1,12 @@
//
// DtoMapper.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
extension AnnotationDto {
func toDomain() -> Annotation {
Annotation(id: id, text: text, created: created, startOffset: startOffset, endOffset: endOffset, startSelector: startSelector, endSelector: endSelector)
}
}

View File

@ -1,11 +1,17 @@
import Foundation
class AnnotationsRepository: PAnnotationsRepository {
private let api: PAPI
init(api: PAPI) {
self.api = api
}
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> Annotation {
try await api.createAnnotation(bookmarkId: bookmarkId, color: color, startOffset: startOffset, endOffset: endOffset, startSelector: startSelector, endSelector: endSelector)
.toDomain()
}
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] {
let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId)
@ -25,4 +31,6 @@ class AnnotationsRepository: PAnnotationsRepository {
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -1,5 +1,6 @@
import Foundation
import CoreData
import Kingfisher
class SettingsRepository: PSettingsRepository {
private let coreDataManager = CoreDataManager.shared
@ -329,4 +330,47 @@ class SettingsRepository: PSettingsRepository {
}
}
// MARK: - Cache Settings
private let maxCacheSizeKey = "KingfisherMaxCacheSize"
func getCacheSize() async throws -> UInt {
return try await withCheckedThrowingContinuation { continuation in
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
switch result {
case .success(let size):
continuation.resume(returning: size)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func getMaxCacheSize() async throws -> UInt {
if let savedSize = userDefault.object(forKey: maxCacheSizeKey) as? UInt {
return savedSize
} else {
// Default: 200 MB
let defaultBytes = UInt(200 * 1024 * 1024)
userDefault.set(defaultBytes, forKey: maxCacheSizeKey)
return defaultBytes
}
}
func updateMaxCacheSize(_ sizeInBytes: UInt) async throws {
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = sizeInBytes
userDefault.set(sizeInBytes, forKey: maxCacheSizeKey)
logger.info("Updated max cache size to \(sizeInBytes) bytes")
}
func clearCache() async throws {
return try await withCheckedThrowingContinuation { continuation in
KingfisherManager.shared.cache.clearDiskCache {
KingfisherManager.shared.cache.clearMemoryCache()
self.logger.info("Cache cleared successfully")
continuation.resume()
}
}
}
}

View File

@ -1,4 +1,5 @@
protocol PAnnotationsRepository {
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> Annotation
}

View File

@ -26,4 +26,10 @@ protocol PSettingsRepository {
// Offline Settings methods
func loadOfflineSettings() async throws -> OfflineSettings
func saveOfflineSettings(_ settings: OfflineSettings) async throws
// Cache Settings methods
func getCacheSize() async throws -> UInt
func getMaxCacheSize() async throws -> UInt
func updateMaxCacheSize(_ sizeInBytes: UInt) async throws
func clearCache() async throws
}

View File

@ -82,6 +82,7 @@
"Favorite" = "Favorit";
"Finished reading?" = "Fertig gelesen?";
"Font" = "Schrift";
"Highlight" = "Markierung";
"Font family" = "Schriftart";
"Font Settings" = "Schrift";
"Font size" = "Schriftgröße";
@ -148,6 +149,8 @@
"Settings" = "Einstellungen";
"Show Performance Logs" = "Performance-Logs anzeigen";
"Show Timestamps" = "Zeitstempel anzeigen";
"Synchronization" = "Synchronisation";
"VPN connections are detected as active internet connections." = "VPN-Verbindungen werden als aktive Internetverbindungen erkannt.";
"Speed" = "Geschwindigkeit";
"Syncing with server..." = "Synchronisiere mit Server...";
"Theme" = "Design";

View File

@ -78,6 +78,7 @@
"Favorite" = "Favorite";
"Finished reading?" = "Finished reading?";
"Font" = "Font";
"Highlight" = "Highlight";
"Font family" = "Font family";
"Font Settings" = "Font Settings";
"Font size" = "Font size";
@ -144,6 +145,8 @@
"Settings" = "Settings";
"Show Performance Logs" = "Show Performance Logs";
"Show Timestamps" = "Show Timestamps";
"Synchronization" = "Synchronization";
"VPN connections are detected as active internet connections." = "VPN connections are detected as active internet connections.";
"Speed" = "Speed";
"Syncing with server..." = "Syncing with server...";
"Theme" = "Theme";

View File

@ -402,6 +402,8 @@ struct NativeWebView: View {
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
let highlightLabel = NSLocalizedString("Highlight", comment: "")
return """
// Create annotation color overlay
(function() {
@ -456,9 +458,9 @@ struct NativeWebView: View {
`;
overlay.appendChild(content);
// Add "Markierung" label
// Add localized label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.textContent = '\(highlightLabel)';
label.style.cssText = `
color: black;
font-size: 16px;

View File

@ -410,6 +410,7 @@ struct WebView: UIViewRepresentable {
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
let highlightLabel = NSLocalizedString("Highlight", comment: "")
return """
// Create annotation color overlay
@ -465,9 +466,9 @@ struct WebView: UIViewRepresentable {
`;
overlay.appendChild(content);
// Add "Markierung" label
// Add localized label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.textContent = '\(highlightLabel)';
label.style.cssText = `
color: black;
font-size: 16px;

View File

@ -4,6 +4,53 @@ 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 2.0.0
### Offline Reading
- **Read your articles without internet connection** - the feature you've been waiting for!
- Automatic background sync keeps your favorite articles cached
- Choose how many articles to cache (up to 200)
- Cache syncs automatically every 4 hours
- Manual sync button for instant updates
- Smart FIFO cleanup automatically removes old cached articles
- Article images are pre-downloaded for offline viewing
- Cached articles load instantly, even without network
### Smart Network Monitoring
- **Automatic offline detection** with reliable network monitoring
- Visual indicator shows when you're offline
- App automatically loads cached articles when offline
- Cache-first loading for instant article access
- Improved VPN handling without false-positives
- Network status checks interface availability for accuracy
### Offline Settings
- **New dedicated offline settings screen**
- Enable or disable offline mode
- Adjust number of cached articles with slider
- View last sync timestamp
- Manual sync button
- Toggle settings work instantly
### Performance & Architecture
- Clean architecture with dedicated cache repository layer
- Efficient CoreData integration for cached content
- Kingfisher image prefetching for smooth offline experience
- Background sync doesn't block app startup
- Reactive updates with Combine framework
### Developer Features (DEBUG)
- Offline mode simulation toggle for testing
- Detailed sync logging for troubleshooting
- Visual debug banner (green=online, red=offline)
---
## Version 1.2.0
### Annotations & Highlighting