ReadKeep/readeck/UI/Bookmarks/BookmarksViewModel.swift
Ilyas Hallak 305b8f733e Implement offline hero image caching with custom cache keys
Major improvements to offline reading functionality:

**Hero Image Offline Support:**
- Add heroImageURL field to BookmarkEntity for persistent storage
- Implement ImageCache-based caching with custom keys (bookmark-{id}-hero)
- Update CachedAsyncImage to support offline loading via cache keys
- Hero images now work offline without URL dependency

**Offline Bookmark Loading:**
- Add proactive offline detection before API calls
- Implement automatic fallback to cached bookmarks when offline
- Fix network status initialization race condition
- Network monitor now checks status synchronously on init

**Core Data Enhancements:**
- Persist hero image URLs in BookmarkEntity.heroImageURL
- Reconstruct ImageResource from cached URLs on offline load
- Add extensive logging for debugging persistence issues

**UI Updates:**
- Update BookmarkDetailView2 to use cache keys for hero images
- Update BookmarkCardView (all 3 layouts) with cache key support
- Improve BookmarksView offline state handling with task-based loading
- Add 50ms delay for network status propagation

**Technical Details:**
- NetworkMonitorRepository: Fix initial status from hardcoded true to actual network check
- BookmarksViewModel: Inject AppSettings for offline detection
- OfflineCacheRepository: Add verification logging for save/load operations
- BookmarkEntityMapper: Sync heroImageURL on save, restore on load

This enables full offline reading with hero images visible in bookmark lists
and detail views, even after app restart.
2025-11-28 23:01:20 +01:00

400 lines
14 KiB
Swift

import Foundation
import Combine
import SwiftUI
@Observable
class BookmarksViewModel {
private let getBooksmarksUseCase: PGetBookmarksUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let offlineCacheRepository: POfflineCacheRepository
weak var appSettings: AppSettings?
var bookmarks: BookmarksPage?
var isLoading = false
var isInitialLoading = true
var errorMessage: String?
var isNetworkError = false
var currentState: BookmarkState = .unread
var currentType = [BookmarkType.article]
var currentTag: String? = nil
var cardLayoutStyle: CardLayoutStyle = .magazine
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>()
private var limit = 50
private var offset = 0
private var hasMoreData = true
private var searchWorkItem: DispatchWorkItem?
var searchQuery: String = "" {
didSet {
throttleSearch()
}
}
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
offlineCacheRepository = OfflineCacheRepository()
setupNotificationObserver()
Task {
await loadCardLayout()
}
}
private func setupNotificationObserver() {
// Listen for card layout changes
NotificationCenter.default
.publisher(for: .cardLayoutChanged)
.sink { notification in
if let layout = notification.object as? CardLayoutStyle {
Task { @MainActor in
self.cardLayoutStyle = layout
}
}
}
.store(in: &cancellables)
// Listen for
NotificationCenter.default
.publisher(for: .addBookmarkFromShare)
.sink { [weak self] notification in
self?.handleShareNotification(notification)
}
.store(in: &cancellables)
}
private func handleShareNotification(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let url = userInfo["url"] as? String,
!url.isEmpty else {
return
}
DispatchQueue.main.async {
self.shareURL = url
self.shareTitle = userInfo["title"] as? String ?? ""
self.showingAddBookmarkFromShare = true
}
}
private func throttleSearch() {
searchWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
Task {
await self.loadBookmarks(state: self.currentState)
}
}
searchWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: workItem)
}
@MainActor
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
guard !isUpdating else {
Logger.viewModel.debug("⏭️ Skipping loadBookmarks - already updating")
return
}
isUpdating = true
defer { isUpdating = false }
isLoading = true
errorMessage = nil
currentState = state
currentType = type
currentTag = tag
offset = 0
hasMoreData = true
// Check if offline BEFORE making API call
Logger.viewModel.info("🔍 Checking network status - appSettings: \(appSettings != nil), isNetworkConnected: \(appSettings?.isNetworkConnected ?? false)")
if let appSettings, !appSettings.isNetworkConnected {
Logger.viewModel.info("📱 Device is offline - loading cached bookmarks")
isNetworkError = true
errorMessage = "No internet connection"
await loadCachedBookmarks()
isLoading = false
isInitialLoading = false
return
}
Logger.viewModel.info("🌐 Device appears online - making API request")
do {
let newBookmarks = try await getBooksmarksUseCase.execute(
state: state,
limit: limit,
offset: offset,
search: searchQuery,
type: type,
tag: tag
)
bookmarks = newBookmarks
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available
isNetworkError = false
} catch {
// Check if it's a network error
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
isNetworkError = true
errorMessage = "No internet connection"
// Try to load cached bookmarks
await loadCachedBookmarks()
default:
isNetworkError = false
errorMessage = "Error loading bookmarks"
}
} else {
isNetworkError = false
errorMessage = "Error loading bookmarks"
}
// Don't clear bookmarks on error - keep existing data visible
}
isLoading = false
isInitialLoading = false
}
@MainActor
private func loadCachedBookmarks() async {
Logger.viewModel.info("📱 loadCachedBookmarks called for state: \(currentState.displayName)")
// Only load cached bookmarks for "Unread" tab
// Other tabs (Archive, Starred, All) should show "offline unavailable" message
guard currentState == .unread else {
Logger.viewModel.info("📱 Skipping cache load for '\(currentState.displayName)' tab - only Unread is cached")
return
}
do {
Logger.viewModel.info("📱 Fetching cached bookmarks from repository...")
let cachedBookmarks = try await offlineCacheRepository.getCachedBookmarks()
Logger.viewModel.info("📱 Retrieved \(cachedBookmarks.count) cached bookmarks")
if !cachedBookmarks.isEmpty {
// Create a BookmarksPage from cached bookmarks
bookmarks = BookmarksPage(
bookmarks: cachedBookmarks,
currentPage: 1,
totalCount: cachedBookmarks.count,
totalPages: 1,
links: nil
)
hasMoreData = false
Logger.viewModel.info("✅ Loaded \(cachedBookmarks.count) cached bookmarks for offline mode")
} else {
Logger.viewModel.warning("⚠️ No cached bookmarks found")
}
} catch {
Logger.viewModel.error("Failed to load cached bookmarks: \(error.localizedDescription)")
}
}
@MainActor
func loadCachedBookmarksFromUI() async {
isNetworkError = true
errorMessage = "No internet connection"
await loadCachedBookmarks()
}
@MainActor
func loadMoreBookmarks() async {
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(
state: currentState,
limit: limit,
offset: offset,
search: nil,
type: currentType,
tag: currentTag)
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
} catch {
// Check if it's a network error
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
isNetworkError = true
errorMessage = "No internet connection"
default:
isNetworkError = false
errorMessage = "Error loading more bookmarks"
}
} else {
isNetworkError = false
errorMessage = "Error loading more bookmarks"
}
}
isLoading = false
}
@MainActor
func refreshBookmarks() async {
await loadBookmarks(state: currentState)
}
@MainActor
func retryLoading() async {
errorMessage = nil
isNetworkError = false
await loadBookmarks(state: currentState, type: currentType, tag: currentTag)
}
@MainActor
func toggleArchive(bookmark: Bookmark) async {
do {
try await updateBookmarkUseCase.toggleArchive(
bookmarkId: bookmark.id,
isArchived: !bookmark.isArchived
)
await loadBookmarks(state: currentState)
} catch {
errorMessage = "Error archiving bookmark"
}
}
@MainActor
func toggleFavorite(bookmark: Bookmark) async {
do {
try await updateBookmarkUseCase.toggleFavorite(
bookmarkId: bookmark.id,
isMarked: !bookmark.isMarked
)
await loadBookmarks(state: currentState)
} catch {
errorMessage = "Error marking bookmark"
}
}
@MainActor
func deleteBookmarkWithUndo(bookmark: Bookmark) {
// Don't remove from UI immediately - just mark as pending
let pendingDelete = PendingDelete(bookmark: bookmark)
pendingDeletes[bookmark.id] = pendingDelete
// Start countdown timer for this specific delete
startDeleteCountdown(for: bookmark.id)
// Schedule actual delete after 3 seconds
let deleteTask = Task {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
// Check if not cancelled and still pending
if !Task.isCancelled, pendingDeletes[bookmark.id] != nil {
await executeDelete(bookmark: bookmark)
await MainActor.run {
// Clean up
pendingDeletes[bookmark.id]?.timer?.invalidate()
pendingDeletes.removeValue(forKey: bookmark.id)
}
}
}
// Store the task in the pending delete
pendingDeletes[bookmark.id]?.deleteTask = deleteTask
}
@MainActor
func undoDelete(bookmarkId: String) {
guard let pendingDelete = pendingDeletes[bookmarkId] else { return }
// Cancel the delete task and timer
pendingDelete.deleteTask?.cancel()
pendingDelete.timer?.invalidate()
// Remove from pending deletes
pendingDeletes.removeValue(forKey: bookmarkId)
}
private func startDeleteCountdown(for bookmarkId: String) {
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
DispatchQueue.main.async {
guard let self = self,
let pendingDelete = self.pendingDeletes[bookmarkId] else {
timer.invalidate()
return
}
pendingDelete.progress += 1.0 / 30.0 // 3 seconds / 0.1 interval = 30 steps
// Trigger UI update by modifying the dictionary
self.pendingDeletes[bookmarkId] = pendingDelete
if pendingDelete.progress >= 1.0 {
timer.invalidate()
}
}
}
pendingDeletes[bookmarkId]?.timer = timer
}
private func executeDelete(bookmark: Bookmark) async {
do {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
// If delete succeeds, remove bookmark from the list
await MainActor.run {
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
}
} catch {
// If delete fails, restore the bookmark
await MainActor.run {
errorMessage = "Error deleting bookmark"
if var currentBookmarks = bookmarks?.bookmarks {
currentBookmarks.insert(bookmark, at: 0)
bookmarks?.bookmarks = currentBookmarks
}
}
}
}
@MainActor
private func loadCardLayout() async {
cardLayoutStyle = await loadCardLayoutUseCase.execute()
}
}
class PendingDelete: Identifiable {
let id = UUID()
let bookmark: Bookmark
var progress: Double = 0.0
var timer: Timer?
var deleteTask: Task<Void, Never>?
init(bookmark: Bookmark) {
self.bookmark = bookmark
}
}