ReadKeep/readeck/UI/Bookmarks/BookmarksView.swift
Ilyas Hallak ef13faeff7 feat: Add offline bookmark sync functionality
Add comprehensive offline bookmark support with sync capabilities:
- Implement offline bookmark storage using Core Data with App Group sharing
- Add Share Extension support for saving bookmarks when server unavailable
- Create LocalBookmarksSyncView for managing offline bookmark queue
- Add OfflineSyncManager for automatic and manual sync operations
- Implement ServerConnectivity monitoring and status handling
- Add badge notifications on More tab for pending offline bookmarks
- Fix tag pagination in Share Extension with unique IDs for proper rendering
- Update PhoneTabView with event-based badge count updates
- Add App Group entitlements for data sharing between main app and extension

The offline system provides seamless bookmark saving when disconnected,
with automatic sync when connection is restored and manual sync options.
2025-08-16 22:32:20 +02:00

219 lines
8.5 KiB
Swift

import Combine
import Foundation
import SwiftUI
struct BookmarksView: View {
@Namespace private var namespace
// 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 = ""
@State private var bookmarkToDelete: Bookmark? = nil
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 {
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
VStack(spacing: 20) {
Spacer()
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)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(R.color.bookmark_list_bg))
} else {
List {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
Button(action: {
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,
onArchive: { bookmark in
Task {
await viewModel.toggleArchive(bookmark: bookmark)
}
},
onDelete: { bookmark in
bookmarkToDelete = bookmark
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
},
namespace: namespace
)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
Task {
await viewModel.loadMoreBookmarks()
}
}
}
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
.matchedTransitionSource(id: bookmark.id, in: namespace)
}
}
.listStyle(.plain)
.background(Color(R.color.bookmark_list_bg))
.scrollContentBackground(.hidden)
.refreshable {
await viewModel.refreshBookmarks()
}
.overlay {
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading {
ContentUnavailableView(
"No bookmarks",
systemImage: "bookmark",
description: Text(
"No bookmarks found in \(state.displayName.lowercased())."
)
)
}
}
}
// FAB Button - only show for "Unread"
if state == .unread || state == .all {
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)
}
}
}
}
.navigationDestination(
item: Binding<String?>(
get: { selectedBookmarkId },
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
.sheet(
isPresented: $viewModel.showingAddBookmarkFromShare,
content: {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
)
.alert(item: $bookmarkToDelete) { bookmark in
Alert(
title: Text("Delete Bookmark"),
message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."),
primaryButton: .destructive(Text("Delete")) {
Task {
await viewModel.deleteBookmark(bookmark: bookmark)
}
},
secondaryButton: .cancel()
)
}
.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()
}
}
}
}
}
#Preview {
BookmarksView(
viewModel: .init(MockUseCaseFactory()),
state: .archived,
type: [.article],
selectedBookmark: .constant(nil),
tag: nil)
}