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.
228 lines
8.1 KiB
Swift
228 lines
8.1 KiB
Swift
import SwiftUI
|
|
|
|
struct ShareBookmarkView: View {
|
|
@ObservedObject var viewModel: ShareBookmarkViewModel
|
|
@State private var keyboardHeight: CGFloat = 0
|
|
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
|
|
|
private func dismissKeyboard() {
|
|
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
logoSection
|
|
serverStatusSection
|
|
urlSection
|
|
tagManagementSection
|
|
.id(AddBookmarkFieldFocus.labels)
|
|
titleSection
|
|
.id(AddBookmarkFieldFocus.title)
|
|
statusSection
|
|
Spacer(minLength: 100) // Space for button
|
|
}
|
|
}
|
|
.padding(.bottom, max(0, keyboardHeight - 120))
|
|
.onChange(of: focusedField) { newField, _ in
|
|
guard let field = newField else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
proxy.scrollTo(field, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
saveButtonSection
|
|
}
|
|
.background(Color(.systemGroupedBackground))
|
|
.onAppear { viewModel.onAppear() }
|
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
dismissKeyboard()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
|
|
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
keyboardHeight = keyboardFrame.height
|
|
}
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
keyboardHeight = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - View Components
|
|
|
|
@ViewBuilder
|
|
private var logoSection: some View {
|
|
Image("readeck")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(height: 40)
|
|
.padding(.top, 24)
|
|
.opacity(0.9)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var serverStatusSection: some View {
|
|
if !viewModel.isServerReachable {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "wifi.exclamationmark")
|
|
.foregroundColor(.orange)
|
|
Text("Server not reachable - saving locally")
|
|
.font(.caption)
|
|
.foregroundColor(.orange)
|
|
}
|
|
.padding(.top, 8)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(Color.orange.opacity(0.1))
|
|
.cornerRadius(8)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var urlSection: some View {
|
|
if let url = viewModel.url {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "link")
|
|
.foregroundColor(.accentColor)
|
|
Text(url)
|
|
.font(.system(size: 15, weight: .bold, design: .default))
|
|
.foregroundColor(.accentColor)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
}
|
|
.padding(.top, 8)
|
|
.padding(.horizontal, 16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var titleSection: some View {
|
|
TextField("Enter an optional title...", text: $viewModel.title)
|
|
.textFieldStyle(CustomTextFieldStyle())
|
|
.font(.system(size: 17, weight: .medium))
|
|
.padding(.horizontal, 10)
|
|
.foregroundColor(.primary)
|
|
.frame(height: 38)
|
|
.padding(.top, 20)
|
|
.padding(.horizontal, 4)
|
|
.frame(maxWidth: 420)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.focused($focusedField, equals: .title)
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .keyboard) {
|
|
Spacer()
|
|
Button("Done") {
|
|
dismissKeyboard()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tagManagementSection: some View {
|
|
if !viewModel.labels.isEmpty || !viewModel.isServerReachable {
|
|
TagManagementView(
|
|
allLabels: convertToBookmarkLabels(viewModel.labels),
|
|
selectedLabels: viewModel.selectedLabels,
|
|
searchText: $viewModel.searchText,
|
|
isLabelsLoading: false,
|
|
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
|
|
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
|
|
searchFieldFocus: $focusedField,
|
|
onAddCustomTag: {
|
|
addCustomTag()
|
|
},
|
|
onToggleLabel: { label in
|
|
if viewModel.selectedLabels.contains(label) {
|
|
viewModel.selectedLabels.remove(label)
|
|
} else {
|
|
viewModel.selectedLabels.insert(label)
|
|
}
|
|
viewModel.searchText = ""
|
|
},
|
|
onRemoveLabel: { label in
|
|
viewModel.selectedLabels.remove(label)
|
|
}
|
|
)
|
|
.padding(.top, 20)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusSection: some View {
|
|
if let status = viewModel.statusMessage {
|
|
Text(status.emoji + " " + status.text)
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundColor(status.isError ? .red : .green)
|
|
.padding(.top, 32)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var saveButtonSection: some View {
|
|
Button(action: { viewModel.save() }) {
|
|
if viewModel.isSaving {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle())
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
} else {
|
|
Text("Save Bookmark")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.accentColor)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(16)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 32)
|
|
.disabled(viewModel.isSaving)
|
|
}
|
|
|
|
// MARK: - Helper Functions
|
|
|
|
private func convertToBookmarkLabels(_ dtos: [BookmarkLabelDto]) -> [BookmarkLabel] {
|
|
return dtos.map { .init(name: $0.name, count: $0.count, href: $0.href) }
|
|
}
|
|
|
|
private func convertToBookmarkLabelPages(_ dtoPages: [[BookmarkLabelDto]]) -> [[BookmarkLabel]] {
|
|
return dtoPages.map { convertToBookmarkLabels($0) }
|
|
}
|
|
|
|
private func addCustomTag() {
|
|
let trimmed = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
|
|
let lowercased = trimmed.lowercased()
|
|
let allExisting = Set(viewModel.labels.map { $0.name.lowercased() })
|
|
let allSelected = Set(viewModel.selectedLabels.map { $0.lowercased() })
|
|
|
|
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
|
// Tag already exists, don't add
|
|
return
|
|
} else {
|
|
viewModel.selectedLabels.insert(trimmed)
|
|
viewModel.searchText = ""
|
|
}
|
|
}
|
|
}
|