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)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for \(endpoint)")
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
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)
|
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Als String dekodieren statt als JSON
|
// Als String dekodieren statt als JSON
|
||||||
guard let string = String(data: data, encoding: .utf8) else {
|
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
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
return string
|
return string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -540,3 +545,16 @@ enum APIError: Error {
|
|||||||
case invalidResponse
|
case invalidResponse
|
||||||
case serverError(Int)
|
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
|
import Foundation
|
||||||
|
|
||||||
class AnnotationsRepository: PAnnotationsRepository {
|
class AnnotationsRepository: PAnnotationsRepository {
|
||||||
|
|
||||||
private let api: PAPI
|
private let api: PAPI
|
||||||
|
|
||||||
init(api: PAPI) {
|
init(api: PAPI) {
|
||||||
self.api = api
|
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] {
|
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] {
|
||||||
let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId)
|
let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId)
|
||||||
@ -25,4 +31,6 @@ class AnnotationsRepository: PAnnotationsRepository {
|
|||||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
||||||
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
class SettingsRepository: PSettingsRepository {
|
class SettingsRepository: PSettingsRepository {
|
||||||
private let coreDataManager = CoreDataManager.shared
|
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 {
|
protocol PAnnotationsRepository {
|
||||||
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
||||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
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
|
// Offline Settings methods
|
||||||
func loadOfflineSettings() async throws -> OfflineSettings
|
func loadOfflineSettings() async throws -> OfflineSettings
|
||||||
func saveOfflineSettings(_ settings: OfflineSettings) async throws
|
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";
|
"Favorite" = "Favorit";
|
||||||
"Finished reading?" = "Fertig gelesen?";
|
"Finished reading?" = "Fertig gelesen?";
|
||||||
"Font" = "Schrift";
|
"Font" = "Schrift";
|
||||||
|
"Highlight" = "Markierung";
|
||||||
"Font family" = "Schriftart";
|
"Font family" = "Schriftart";
|
||||||
"Font Settings" = "Schrift";
|
"Font Settings" = "Schrift";
|
||||||
"Font size" = "Schriftgröße";
|
"Font size" = "Schriftgröße";
|
||||||
@ -148,6 +149,8 @@
|
|||||||
"Settings" = "Einstellungen";
|
"Settings" = "Einstellungen";
|
||||||
"Show Performance Logs" = "Performance-Logs anzeigen";
|
"Show Performance Logs" = "Performance-Logs anzeigen";
|
||||||
"Show Timestamps" = "Zeitstempel anzeigen";
|
"Show Timestamps" = "Zeitstempel anzeigen";
|
||||||
|
"Synchronization" = "Synchronisation";
|
||||||
|
"VPN connections are detected as active internet connections." = "VPN-Verbindungen werden als aktive Internetverbindungen erkannt.";
|
||||||
"Speed" = "Geschwindigkeit";
|
"Speed" = "Geschwindigkeit";
|
||||||
"Syncing with server..." = "Synchronisiere mit Server...";
|
"Syncing with server..." = "Synchronisiere mit Server...";
|
||||||
"Theme" = "Design";
|
"Theme" = "Design";
|
||||||
|
|||||||
@ -78,6 +78,7 @@
|
|||||||
"Favorite" = "Favorite";
|
"Favorite" = "Favorite";
|
||||||
"Finished reading?" = "Finished reading?";
|
"Finished reading?" = "Finished reading?";
|
||||||
"Font" = "Font";
|
"Font" = "Font";
|
||||||
|
"Highlight" = "Highlight";
|
||||||
"Font family" = "Font family";
|
"Font family" = "Font family";
|
||||||
"Font Settings" = "Font Settings";
|
"Font Settings" = "Font Settings";
|
||||||
"Font size" = "Font size";
|
"Font size" = "Font size";
|
||||||
@ -144,6 +145,8 @@
|
|||||||
"Settings" = "Settings";
|
"Settings" = "Settings";
|
||||||
"Show Performance Logs" = "Show Performance Logs";
|
"Show Performance Logs" = "Show Performance Logs";
|
||||||
"Show Timestamps" = "Show Timestamps";
|
"Show Timestamps" = "Show Timestamps";
|
||||||
|
"Synchronization" = "Synchronization";
|
||||||
|
"VPN connections are detected as active internet connections." = "VPN connections are detected as active internet connections.";
|
||||||
"Speed" = "Speed";
|
"Speed" = "Speed";
|
||||||
"Syncing with server..." = "Syncing with server...";
|
"Syncing with server..." = "Syncing with server...";
|
||||||
"Theme" = "Theme";
|
"Theme" = "Theme";
|
||||||
|
|||||||
@ -402,6 +402,8 @@ struct NativeWebView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
|
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
|
||||||
|
let highlightLabel = NSLocalizedString("Highlight", comment: "")
|
||||||
|
|
||||||
return """
|
return """
|
||||||
// Create annotation color overlay
|
// Create annotation color overlay
|
||||||
(function() {
|
(function() {
|
||||||
@ -456,9 +458,9 @@ struct NativeWebView: View {
|
|||||||
`;
|
`;
|
||||||
overlay.appendChild(content);
|
overlay.appendChild(content);
|
||||||
|
|
||||||
// Add "Markierung" label
|
// Add localized label
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.textContent = 'Markierung';
|
label.textContent = '\(highlightLabel)';
|
||||||
label.style.cssText = `
|
label.style.cssText = `
|
||||||
color: black;
|
color: black;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
@ -410,6 +410,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
|
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
|
||||||
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
|
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
|
||||||
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
|
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
|
||||||
|
let highlightLabel = NSLocalizedString("Highlight", comment: "")
|
||||||
|
|
||||||
return """
|
return """
|
||||||
// Create annotation color overlay
|
// Create annotation color overlay
|
||||||
@ -465,9 +466,9 @@ struct WebView: UIViewRepresentable {
|
|||||||
`;
|
`;
|
||||||
overlay.appendChild(content);
|
overlay.appendChild(content);
|
||||||
|
|
||||||
// Add "Markierung" label
|
// Add localized label
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.textContent = 'Markierung';
|
label.textContent = '\(highlightLabel)';
|
||||||
label.style.cssText = `
|
label.style.cssText = `
|
||||||
color: black;
|
color: black;
|
||||||
font-size: 16px;
|
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.
|
**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
|
## Version 1.2.0
|
||||||
|
|
||||||
### Annotations & Highlighting
|
### Annotations & Highlighting
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user