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.
415 lines
14 KiB
Swift
415 lines
14 KiB
Swift
import Combine
|
||
import Foundation
|
||
import SwiftUI
|
||
|
||
struct BookmarksView: View {
|
||
|
||
// MARK: States
|
||
|
||
@State private var viewModel = BookmarksViewModel()
|
||
@State private var showingAddBookmark = false
|
||
@State private var selectedBookmarkId: String?
|
||
@State private var showingAddBookmarkFromShare = false
|
||
@State private var shareURL = ""
|
||
@State private var shareTitle = ""
|
||
|
||
let state: BookmarkState
|
||
let type: [BookmarkType]
|
||
@Binding var selectedBookmark: Bookmark?
|
||
@EnvironmentObject var playerUIState: PlayerUIState
|
||
@EnvironmentObject var appSettings: AppSettings
|
||
let tag: String?
|
||
|
||
// MARK: Environments
|
||
|
||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||
|
||
// MARK: Initializer
|
||
|
||
init(state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
|
||
self.state = state
|
||
self.type = type
|
||
self._selectedBookmark = selectedBookmark
|
||
self.tag = tag
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
VStack(spacing: 0) {
|
||
// Offline banner
|
||
if !appSettings.isNetworkConnected && (viewModel.bookmarks?.bookmarks.isEmpty == false) {
|
||
offlineBanner
|
||
}
|
||
|
||
// Main content
|
||
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
|
||
skeletonLoadingView
|
||
} else if shouldShowCenteredState {
|
||
centeredStateView
|
||
} else {
|
||
bookmarksList
|
||
}
|
||
}
|
||
|
||
// FAB Button - only show for "Unread" and when not in error/loading state
|
||
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
|
||
fabButton
|
||
}
|
||
}
|
||
.navigationDestination(
|
||
item: Binding<String?>(
|
||
get: { selectedBookmarkId },
|
||
set: { selectedBookmarkId = $0 }
|
||
)
|
||
) { bookmarkId in
|
||
BookmarkDetailView(bookmarkId: bookmarkId)
|
||
.toolbar(.hidden, for: .tabBar)
|
||
}
|
||
.sheet(isPresented: $showingAddBookmark) {
|
||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||
}
|
||
.sheet(
|
||
isPresented: $viewModel.showingAddBookmarkFromShare,
|
||
content: {
|
||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||
}
|
||
)
|
||
.task {
|
||
// Set appSettings reference
|
||
viewModel.appSettings = appSettings
|
||
|
||
// Wait briefly for initial network status to be set
|
||
// NetworkMonitor checks status synchronously in init, but the publisher
|
||
// might not have propagated to appSettings yet
|
||
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
||
|
||
Logger.ui.info("📲 BookmarksView.task - Loading bookmarks, isNetworkConnected: \(appSettings.isNetworkConnected)")
|
||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||
}
|
||
.onChange(of: showingAddBookmark) { oldValue, newValue in
|
||
// Refresh bookmarks when sheet is dismissed
|
||
if oldValue && !newValue {
|
||
Task {
|
||
// Wait a bit for the server to process the new bookmark
|
||
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
||
|
||
await viewModel.refreshBookmarks()
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: appSettings.isNetworkConnected) { oldValue, newValue in
|
||
// Network status changed
|
||
if !newValue && oldValue {
|
||
// Lost network connection - load cached bookmarks
|
||
Task {
|
||
await viewModel.loadCachedBookmarksFromUI()
|
||
}
|
||
} else if newValue && !oldValue {
|
||
// Regained network connection - refresh from server
|
||
Task {
|
||
await viewModel.refreshBookmarks()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Computed Properties
|
||
|
||
private var shouldShowCenteredState: Bool {
|
||
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
|
||
let hasError = viewModel.errorMessage != nil
|
||
let isOfflineNonUnread = !appSettings.isNetworkConnected && state != .unread
|
||
|
||
// Show centered state when:
|
||
// 1. Empty AND has error, OR
|
||
// 2. Offline mode in non-Unread tabs (Archive/Starred/All)
|
||
return (isEmpty && hasError) || isOfflineNonUnread
|
||
}
|
||
|
||
// MARK: - View Components
|
||
|
||
@ViewBuilder
|
||
private var centeredStateView: some View {
|
||
VStack(spacing: 20) {
|
||
Spacer()
|
||
|
||
if !appSettings.isNetworkConnected && state != .unread {
|
||
offlineUnavailableView
|
||
} else if viewModel.isLoading {
|
||
loadingView
|
||
} else if let errorMessage = viewModel.errorMessage {
|
||
errorView(message: errorMessage)
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(Color(R.color.bookmark_list_bg))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var loadingView: some View {
|
||
VStack(spacing: 16) {
|
||
ProgressView()
|
||
.scaleEffect(1.3)
|
||
.tint(.accentColor)
|
||
|
||
VStack(spacing: 8) {
|
||
Text("Loading \(state.displayName)")
|
||
.font(.headline)
|
||
.foregroundColor(.primary)
|
||
|
||
Text("Please wait while we fetch your bookmarks...")
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
}
|
||
.padding(.horizontal, 40)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var offlineUnavailableView: some View {
|
||
VStack(spacing: 20) {
|
||
// Icon stack
|
||
ZStack {
|
||
Image(systemName: "cloud.slash")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.secondary.opacity(0.3))
|
||
.offset(x: -8, y: 8)
|
||
|
||
Image(systemName: "wifi.slash")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.orange)
|
||
}
|
||
|
||
VStack(spacing: 8) {
|
||
Text("Offline Mode")
|
||
.font(.title2)
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(.primary)
|
||
|
||
Text("\(state.displayName) Not Available")
|
||
.font(.headline)
|
||
.foregroundColor(.secondary)
|
||
|
||
Text("Only unread articles are cached for offline reading")
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 4)
|
||
}
|
||
|
||
// Hint to switch to Unread tab
|
||
VStack(spacing: 8) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "arrow.left")
|
||
.font(.caption)
|
||
Text("Switch to Unread to view cached articles")
|
||
.font(.caption)
|
||
}
|
||
.foregroundColor(.accentColor)
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
.background(Color.accentColor.opacity(0.1))
|
||
.clipShape(Capsule())
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
.padding(.horizontal, 40)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func errorView(message: String) -> some View {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: viewModel.isNetworkError ? "wifi.slash" : "exclamationmark.triangle.fill")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.orange)
|
||
|
||
VStack(spacing: 8) {
|
||
Text(viewModel.isNetworkError ? "No internet connection" : "Unable to load bookmarks")
|
||
.font(.headline)
|
||
.foregroundColor(.primary)
|
||
|
||
Text(viewModel.isNetworkError ? "Please check your internet connection and try again" : message)
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
|
||
Button("Try Again") {
|
||
Task {
|
||
await viewModel.retryLoading()
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
}
|
||
.padding(.horizontal, 40)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var bookmarksList: some View {
|
||
List {
|
||
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
||
Button(action: {
|
||
// Don't navigate to detail if bookmark is pending deletion
|
||
if viewModel.pendingDeletes[bookmark.id] != nil {
|
||
return
|
||
}
|
||
|
||
if UIDevice.isPhone {
|
||
selectedBookmarkId = bookmark.id
|
||
} else {
|
||
if selectedBookmark?.id == bookmark.id {
|
||
selectedBookmark = nil
|
||
DispatchQueue.main.async {
|
||
selectedBookmark = bookmark
|
||
}
|
||
} else {
|
||
selectedBookmark = bookmark
|
||
}
|
||
}
|
||
}) {
|
||
BookmarkCardView(
|
||
bookmark: bookmark,
|
||
currentState: state,
|
||
layout: viewModel.cardLayoutStyle,
|
||
pendingDelete: viewModel.pendingDeletes[bookmark.id],
|
||
onArchive: { bookmark in
|
||
Task {
|
||
await viewModel.toggleArchive(bookmark: bookmark)
|
||
}
|
||
},
|
||
onDelete: { bookmark in
|
||
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
|
||
},
|
||
onToggleFavorite: { bookmark in
|
||
Task {
|
||
await viewModel.toggleFavorite(bookmark: bookmark)
|
||
}
|
||
},
|
||
onUndoDelete: { bookmarkId in
|
||
viewModel.undoDelete(bookmarkId: bookmarkId)
|
||
}
|
||
)
|
||
.onAppear {
|
||
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
||
Task {
|
||
await viewModel.loadMoreBookmarks()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
.listRowInsets(EdgeInsets(
|
||
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||
leading: 16,
|
||
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||
trailing: 16
|
||
))
|
||
.listRowSeparator(.hidden)
|
||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||
}
|
||
|
||
// Show loading indicator for pagination
|
||
if viewModel.isLoading && !(viewModel.bookmarks?.bookmarks.isEmpty == true) {
|
||
HStack {
|
||
Spacer()
|
||
ProgressView()
|
||
.scaleEffect(0.8)
|
||
Spacer()
|
||
}
|
||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.background(Color(R.color.bookmark_list_bg))
|
||
.scrollContentBackground(.hidden)
|
||
.refreshable {
|
||
await viewModel.refreshBookmarks()
|
||
}
|
||
.overlay {
|
||
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading && viewModel.errorMessage == nil {
|
||
ContentUnavailableView(
|
||
"No bookmarks",
|
||
systemImage: "bookmark",
|
||
description: Text(
|
||
"No bookmarks found in \(state.displayName.lowercased())."
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var skeletonLoadingView: some View {
|
||
ScrollView {
|
||
SkeletonLoadingView(layout: viewModel.cardLayoutStyle)
|
||
.padding(
|
||
EdgeInsets(
|
||
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||
leading: 16,
|
||
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||
trailing: 16
|
||
)
|
||
)
|
||
}
|
||
.background(Color(R.color.bookmark_list_bg))
|
||
.refreshable {
|
||
await viewModel.refreshBookmarks()
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var offlineBanner: some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "wifi.slash")
|
||
.font(.body)
|
||
.foregroundColor(.secondary)
|
||
|
||
Text("Offline Mode – Showing cached articles")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
.background(Color(.systemGray6))
|
||
.overlay(
|
||
Rectangle()
|
||
.frame(height: 0.5)
|
||
.foregroundColor(Color(.separator)),
|
||
alignment: .bottom
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var fabButton: some View {
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Spacer()
|
||
|
||
Button(action: {
|
||
showingAddBookmark = true
|
||
}) {
|
||
Image(systemName: "plus")
|
||
.font(.title2)
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(.white)
|
||
.frame(width: 56, height: 56)
|
||
.background(Color.accentColor)
|
||
.clipShape(Circle())
|
||
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3)
|
||
}
|
||
.padding(.trailing, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
}
|
||
}
|