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:
parent
4fd55ef5d0
commit
8dc5f3000a
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
readeck/Data/Mappers/DtoMapper.swift
Normal file
12
readeck/Data/Mappers/DtoMapper.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user