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.
400 lines
14 KiB
Swift
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
|
|
}
|
|
}
|