Phase 4 - Settings UI: - Add OfflineSettingsViewModel with reactive bindings - Add OfflineSettingsView with toggle, slider, sync button - Integrate into SettingsContainerView - Extend factories with offline dependencies - Add debug button to simulate offline mode (DEBUG only) Phase 5 - App Integration: - AppViewModel: Auto-sync on app start with 4h check - BookmarksViewModel: Offline fallback loading cached articles - BookmarksView: Offline banner when network unavailable - BookmarkDetailViewModel: Cache-first article loading - Fix concurrency issues with CurrentValueSubject Features: - Background sync on app start (non-blocking) - Cached bookmarks shown when offline - Instant article loading from cache - Visual offline indicator banner - Full offline reading experience All features compile and build successfully.
346 lines
12 KiB
Swift
346 lines
12 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
|
||
let tag: String?
|
||
|
||
// MARK: Environments
|
||
|
||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||
|
||
// MARK: Initializer
|
||
|
||
init(viewModel: BookmarksViewModel = .init(), state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
|
||
self.state = state
|
||
self.type = type
|
||
self._selectedBookmark = selectedBookmark
|
||
self.tag = tag
|
||
self.viewModel = viewModel
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
VStack(spacing: 0) {
|
||
// Offline banner
|
||
if viewModel.isNetworkError && (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)
|
||
}
|
||
)
|
||
.onAppear {
|
||
Task {
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Computed Properties
|
||
|
||
private var shouldShowCenteredState: Bool {
|
||
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
|
||
let hasError = viewModel.errorMessage != nil
|
||
// Only show centered state when empty AND error (not just error)
|
||
return isEmpty && hasError
|
||
}
|
||
|
||
// MARK: - View Components
|
||
|
||
@ViewBuilder
|
||
private var centeredStateView: some View {
|
||
VStack(spacing: 20) {
|
||
Spacer()
|
||
|
||
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 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-Modus – Zeige gespeicherte Artikel")
|
||
.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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
BookmarksView(
|
||
viewModel: .init(MockUseCaseFactory()),
|
||
state: .archived,
|
||
type: [.article],
|
||
selectedBookmark: .constant(nil),
|
||
tag: nil)
|
||
}
|