diff --git a/Localizable.xcstrings b/Localizable.xcstrings index d2dff21..4edf2a2 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -67,12 +67,6 @@ } } } - }, - "All available tags" : { - - }, - "All labels selected" : { - }, "All tags selected" : { @@ -94,9 +88,6 @@ }, "Automatically mark articles as read" : { - }, - "Available labels" : { - }, "Available tags" : { @@ -109,9 +100,6 @@ }, "Close" : { - }, - "Current tags" : { - }, "Data Management" : { @@ -187,9 +175,6 @@ }, "Loading article..." : { - }, - "Loading tags..." : { - }, "Login & Save" : { @@ -217,12 +202,6 @@ }, "No bookmarks found in %@." : { - }, - "No tags available" : { - - }, - "No tags found" : { - }, "OK" : { diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift index d468f65..bf13cfd 100644 --- a/URLShare/ShareBookmarkView.swift +++ b/URLShare/ShareBookmarkView.swift @@ -2,193 +2,39 @@ import SwiftUI struct ShareBookmarkView: View { @ObservedObject var viewModel: ShareBookmarkViewModel + @State private var keyboardHeight: CGFloat = 0 + @State private var shouldScrollToTitle = false private func dismissKeyboard() { NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil) } var body: some View { - ScrollView { - VStack(spacing: 0) { - // Logo - Image("readeck") - .resizable() - .scaledToFit() - .frame(height: 40) - .padding(.top, 24) - .opacity(0.9) - // URL - 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) + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + logoSection + urlSection + tagManagementSection + titleSection + .id("titleField") + statusSection + Spacer(minLength: 100) // Space for button } - .padding(.top, 8) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, alignment: .leading) } - // Title - TextField("Enter an optional title...", text: $viewModel.title) - .font(.system(size: 17, weight: .medium)) - .padding(.horizontal, 10) - .foregroundColor(.primary) - .frame(height: 38) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - .shadow(color: Color.black.opacity(0.04), radius: 2, x: 0, y: 1) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.accentColor.opacity(viewModel.title.isEmpty ? 0.12 : 0.7), lineWidth: viewModel.title.isEmpty ? 1 : 2) - ) - .padding(.top, 20) - .padding(.horizontal, 16) - .frame(maxWidth: 420) - .frame(maxWidth: .infinity, alignment: .center) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - dismissKeyboard() - } + .padding(.bottom, keyboardHeight / 2) + .onChange(of: shouldScrollToTitle) { shouldScroll, _ in + if shouldScroll { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo("titleField", anchor: .center) } - } - - // Manual tag entry (always visible) - ManualTagEntryView( - labels: viewModel.labels, - selectedLabels: $viewModel.selectedLabels, - searchText: $viewModel.searchText - ) - .padding(.top, 20) - .padding(.horizontal, 16) - - // Unified Labels Section - if !viewModel.labels.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Available labels") - .font(.headline) - if !viewModel.searchText.isEmpty { - Text("(\(viewModel.filteredLabels.count) found)") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - .padding(.horizontal) - - if viewModel.availableLabels.isEmpty { - // All labels are selected - VStack { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 40)) - .foregroundColor(.green) - .padding(.vertical, 20) - Text("All labels selected") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .frame(height: 180) - } else { - // Use pagination from ViewModel - TabView { - ForEach(Array(viewModel.availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - ForEach(labelsPage, id: \.name) { label in - UnifiedLabelChip( - label: label.name, - isSelected: viewModel.selectedLabels.contains(label.name), - isRemovable: false, - onTap: { - if viewModel.selectedLabels.contains(label.name) { - viewModel.selectedLabels.remove(label.name) - } else { - viewModel.selectedLabels.insert(label.name) - } - viewModel.searchText = "" - } - ) - } - } - .frame(maxWidth: .infinity, alignment: .top) - .padding(.horizontal) - } - } - .tabViewStyle(PageTabViewStyle(indexDisplayMode: viewModel.availableLabelPages.count > 1 ? .automatic : .never)) - .frame(height: 180) - .padding(.top, -20) - } - } - .padding(.top, 32) - .frame(minHeight: 100) - } - - // Current selected labels - if !viewModel.selectedLabels.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Selected tags") - .font(.headline) - .padding(.horizontal) - - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - ForEach(Array(viewModel.selectedLabels), id: \.self) { label in - UnifiedLabelChip( - label: label, - isSelected: false, - isRemovable: true, - onTap: { - // No action for selected labels - }, - onRemove: { - viewModel.selectedLabels.remove(label) - } - ) - } - } - .padding(.horizontal) - } - .padding(.top, 20) - } - - // Status - 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) - } - - // Save Button - 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) + shouldScrollToTitle = false } } - .padding(.horizontal, 16) - .padding(.top, 32) - .padding(.bottom, 32) - .disabled(viewModel.isSaving) } + + saveButtonSection } .background(Color(.systemGroupedBackground)) .onAppear { viewModel.onAppear() } @@ -201,93 +47,162 @@ struct ShareBookmarkView: View { dismissKeyboard() } ) - } -} - -struct ManualTagEntryView: View { - let labels: [BookmarkLabelDto] - @Binding var selectedLabels: Set - @Binding var searchText: String - @State private var error: String? = nil - - private func dismissKeyboard() { - NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil) - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - // Search field - TextField("Search or add new tag...", text: $searchText) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .autocapitalization(.none) - .disableAutocorrection(true) - .onSubmit { - addCustomTag() + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in + if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + keyboardHeight = keyboardFrame.height + // Scroll to title field when keyboard appears + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + shouldScrollToTitle = true } - - // Show custom tag suggestion if search text doesn't match existing tags - if !searchText.isEmpty && !labels.contains(where: { $0.name.lowercased() == searchText.lowercased() }) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Add new tag:") - .font(.subheadline) - .foregroundColor(.secondary) - Text(searchText) - .font(.headline) - .fontWeight(.medium) - } - Spacer() - Button(action: addCustomTag) { - HStack(spacing: 6) { - Image(systemName: "plus.circle.fill") - .font(.title3) - Text("Add") - .font(.subheadline) - .fontWeight(.medium) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 12) - .background(Color.accentColor.opacity(0.1)) - .cornerRadius(10) - } - - if let error = error { - Text(error) - .font(.caption) - .foregroundColor(.red) } } - .background( - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - // Fallback for extensions: tap anywhere to dismiss keyboard - dismissKeyboard() + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + 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 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) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + dismissKeyboard() + } } - ) + } + } + + @ViewBuilder + private var tagManagementSection: some View { + if !viewModel.labels.isEmpty { + TagManagementView( + allLabels: convertToBookmarkLabels(viewModel.labels), + selectedLabels: viewModel.selectedLabels, + searchText: $viewModel.searchText, + isLabelsLoading: false, + availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages), + filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels), + 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) + .background(Color(.systemGroupedBackground)) + } + + // 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 = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let lowercased = trimmed.lowercased() - let allExisting = Set(labels.map { $0.name.lowercased() }) - let allSelected = Set(selectedLabels.map { $0.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) { - error = "Tag already exists." + // Tag already exists, don't add + return } else { - selectedLabels.insert(trimmed) - searchText = "" - error = nil + viewModel.selectedLabels.insert(trimmed) + viewModel.searchText = "" } } -} +} diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 10c6f1e..3d0dd19 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -85,9 +85,12 @@ Data/CoreData/CoreDataManager.swift, Data/KeychainHelper.swift, Domain/Model/Bookmark.swift, + Domain/Model/BookmarkLabel.swift, readeck.xcdatamodeld, Splash.storyboard, UI/Components/Constants.swift, + UI/Components/CustomTextFieldStyle.swift, + UI/Components/TagManagementView.swift, UI/Components/UnifiedLabelChip.swift, ); target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */; @@ -620,7 +623,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -664,7 +667,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; diff --git a/readeck/UI/AddBookmark/AddBookmarkView.swift b/readeck/UI/AddBookmark/AddBookmarkView.swift index 9ee939f..2385f8d 100644 --- a/readeck/UI/AddBookmark/AddBookmarkView.swift +++ b/readeck/UI/AddBookmark/AddBookmarkView.swift @@ -4,6 +4,8 @@ import UIKit struct AddBookmarkView: View { @State private var viewModel = AddBookmarkViewModel() @Environment(\.dismiss) private var dismiss + @FocusState private var focusedField: AddBookmarkFieldFocus? + @State private var keyboardHeight: CGFloat = 0 init(prefilledURL: String? = nil, prefilledTitle: String? = nil) { _viewModel = State(initialValue: AddBookmarkViewModel()) @@ -18,232 +20,8 @@ struct AddBookmarkView: View { var body: some View { NavigationView { VStack(spacing: 0) { - // Scrollable Form Content - ScrollView { - VStack(spacing: 20) { - // Form Fields - VStack(spacing: 20) { - // URL Field - VStack(alignment: .leading, spacing: 8) { - TextField("https://example.com", text: $viewModel.url) - .textFieldStyle(CustomTextFieldStyle()) - .keyboardType(.URL) - .autocapitalization(.none) - .autocorrectionDisabled() - .onChange(of: viewModel.url) { _, _ in - viewModel.checkClipboard() - } - - // Clipboard Button - only show if we have a URL in clipboard - if viewModel.showClipboardButton { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("URL in clipboard:") - .font(.caption) - .foregroundColor(.secondary) - - Text(viewModel.clipboardURL ?? "") - .font(.subheadline) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer() - - HStack(spacing: 8) { - Button("Paste") { - viewModel.pasteFromClipboard() - } - .buttonStyle(SecondaryButtonStyle()) - - Button(action: { - viewModel.dismissClipboard() - }) { - Image(systemName: "xmark.circle.fill") - .font(.caption) - .foregroundColor(.secondary) - } - } - } - .padding() - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - // Title Field - VStack(alignment: .leading, spacing: 8) { - TextField("Optional: Custom title", text: $viewModel.title) - .textFieldStyle(CustomTextFieldStyle()) - } - - // Labels Field - VStack(alignment: .leading, spacing: 8) { - // Search field for tags - TextField("Search or add new tag...", text: $viewModel.searchText) - .textFieldStyle(CustomTextFieldStyle()) - .onSubmit { - viewModel.addCustomTag() - } - - // Show custom tag suggestion if search text doesn't match existing tags - if !viewModel.searchText.isEmpty && !viewModel.allLabels.contains(where: { $0.name.lowercased() == viewModel.searchText.lowercased() }) && !viewModel.selectedLabels.contains(viewModel.searchText) { - HStack { - Text("Add new tag:") - .font(.caption) - .foregroundColor(.secondary) - Text(viewModel.searchText) - .font(.caption) - .fontWeight(.medium) - Spacer() - Button(action: { - viewModel.addCustomTag() - }) { - HStack(spacing: 6) { - Image(systemName: "plus.circle.fill") - .font(.caption) - Text("Add") - .font(.caption) - .fontWeight(.medium) - } - } - .foregroundColor(.accentColor) - } - .padding(.horizontal, 12) - .padding(.vertical, 12) - .background(Color.accentColor.opacity(0.1)) - .cornerRadius(10) - } - - // Available labels - if !viewModel.allLabels.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(viewModel.searchText.isEmpty ? "Available tags" : "Search results") - .font(.subheadline) - .fontWeight(.medium) - if !viewModel.searchText.isEmpty { - Text("(\(viewModel.filteredLabels.count) found)") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - - if viewModel.isLabelsLoading { - ProgressView() - .scaleEffect(0.8) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 20) - } else if viewModel.availableLabels.isEmpty { - VStack { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 24)) - .foregroundColor(.green) - Text("All tags selected") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 20) - } else { - // Use pagination from ViewModel - TabView { - ForEach(Array(viewModel.availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - ForEach(labelsPage, id: \.id) { label in - UnifiedLabelChip( - label: label.name, - isSelected: viewModel.selectedLabels.contains(label.name), - isRemovable: false, - onTap: { - viewModel.toggleLabel(label.name) - } - ) - } - } - .frame(maxWidth: .infinity, alignment: .top) - .padding(.horizontal) - } - } - .tabViewStyle(.page(indexDisplayMode: viewModel.availableLabelPages.count > 1 ? .automatic : .never)) - .frame(height: 180) - .padding(.top, -20) - } - } - .padding(.top, 8) - } - - // Selected labels - if !viewModel.selectedLabels.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Selected tags") - .font(.subheadline) - .fontWeight(.medium) - - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - ForEach(Array(viewModel.selectedLabels), id: \.self) { label in - UnifiedLabelChip( - label: label, - isSelected: false, - isRemovable: true, - onTap: { - // No action for selected labels - }, - onRemove: { - viewModel.removeLabel(label) - } - ) - } - } - } - .padding(.top, 8) - } - } - } - .padding(.horizontal, 20) - - Spacer(minLength: 120) // Space for button - } - } - - // Bottom Action Area - VStack(spacing: 16) { - VStack(spacing: 12) { - // Save Button - Button(action: { - Task { - await viewModel.createBookmark() - if viewModel.hasCreated { - dismiss() - } - } - }) { - HStack { - if viewModel.isLoading { - ProgressView() - .scaleEffect(0.8) - .foregroundColor(.white) - } else { - Image(systemName: "bookmark.fill") - } - - Text(viewModel.isLoading ? "Saving..." : "Save bookmark") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(viewModel.isValid && !viewModel.isLoading ? Color.accentColor : Color.gray) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .disabled(!viewModel.isValid || viewModel.isLoading) - } - .padding(.horizontal, 20) - .padding(.bottom, 20) - } - .background(Color(.systemBackground)) + formContent + bottomActionArea } .navigationTitle("New Bookmark") .navigationBarTitleDisplayMode(.inline) @@ -269,6 +47,14 @@ struct AddBookmarkView: View { } } .ignoresSafeArea(.keyboard) + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in + if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + keyboardHeight = keyboardFrame.height + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + keyboardHeight = 0 + } } .onAppear { viewModel.checkClipboard() @@ -280,23 +66,184 @@ struct AddBookmarkView: View { viewModel.clearForm() } } -} - -// MARK: - Custom Styles - -struct CustomTextFieldStyle: TextFieldStyle { - func _body(configuration: TextField) -> some View { - configuration + + // MARK: - View Components + + @ViewBuilder + private var formContent: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 20) { + VStack(spacing: 20) { + urlField + .id("urlField") + Spacer() + .frame(height: 40) + .id("labelsOffset") + labelsField + .id("labelsField") + titleField + .id("titleField") + } + .padding(.horizontal, 20) + + Spacer(minLength: 120) + } + .padding(.top, 20) // Add top padding for offset + } + .padding(.bottom, keyboardHeight / 2) + .onChange(of: focusedField) { field in + guard let field = field else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeInOut(duration: 0.3)) { + switch field { + case .url: + proxy.scrollTo("urlField", anchor: .top) + case .labels: + proxy.scrollTo("labelsOffset", anchor: .top) + case .title: + proxy.scrollTo("titleField", anchor: .center) + } + } + } + } + } + } + + @ViewBuilder + private var urlField: some View { + VStack(alignment: .leading, spacing: 8) { + TextField("https://example.com", text: $viewModel.url) + .textFieldStyle(CustomTextFieldStyle()) + .keyboardType(.URL) + .autocapitalization(.none) + .autocorrectionDisabled() + .focused($focusedField, equals: .url) + .onChange(of: viewModel.url) { _, _ in + viewModel.checkClipboard() + } + + clipboardButton + } + } + + @ViewBuilder + private var clipboardButton: some View { + if viewModel.showClipboardButton { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("URL in clipboard:") + .font(.caption) + .foregroundColor(.secondary) + + Text(viewModel.clipboardURL ?? "") + .font(.subheadline) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + HStack(spacing: 8) { + Button("Paste") { + viewModel.pasteFromClipboard() + } + .buttonStyle(SecondaryButtonStyle()) + + Button(action: { + viewModel.dismissClipboard() + }) { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundColor(.secondary) + } + } + } .padding() .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color(.systemGray4), lineWidth: 1) - ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + @ViewBuilder + private var titleField: some View { + VStack(alignment: .leading, spacing: 8) { + TextField("Optional: Custom title", text: $viewModel.title) + .textFieldStyle(CustomTextFieldStyle()) + .focused($focusedField, equals: .title) + } + } + + @ViewBuilder + private var labelsField: some View { + TagManagementView( + allLabels: viewModel.allLabels, + selectedLabels: viewModel.selectedLabels, + searchText: $viewModel.searchText, + isLabelsLoading: viewModel.isLabelsLoading, + availableLabelPages: viewModel.availableLabelPages, + filteredLabels: viewModel.filteredLabels, + searchFieldFocus: $focusedField, + onAddCustomTag: { + viewModel.addCustomTag() + }, + onToggleLabel: { label in + viewModel.toggleLabel(label) + }, + onRemoveLabel: { label in + viewModel.removeLabel(label) + } + ) + } + + @ViewBuilder + private var bottomActionArea: some View { + VStack(spacing: 16) { + VStack(spacing: 12) { + saveButton + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .background(Color(.systemBackground)) + } + + @ViewBuilder + private var saveButton: some View { + Button(action: { + Task { + await viewModel.createBookmark() + if viewModel.hasCreated { + dismiss() + } + } + }) { + HStack { + if viewModel.isLoading { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(.white) + } else { + Image(systemName: "bookmark.fill") + } + + Text(viewModel.isLoading ? "Saving..." : "Save bookmark") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(viewModel.isValid && !viewModel.isLoading ? Color.accentColor : Color.gray) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(!viewModel.isValid || viewModel.isLoading) } } +// MARK: - Custom Styles + struct SecondaryButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label diff --git a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift index d84144f..fabe3ee 100644 --- a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift +++ b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift @@ -3,26 +3,38 @@ import UIKit @Observable class AddBookmarkViewModel { + + // MARK: - Dependencies + private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase() private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase() + // MARK: - Form Data var url: String = "" var title: String = "" var labelsText: String = "" - // Tag functionality + // MARK: - Labels/Tags Management + var allLabels: [BookmarkLabel] = [] var selectedLabels: Set = [] var searchText: String = "" var isLabelsLoading: Bool = false + // MARK: - UI State + var isLoading: Bool = false var errorMessage: String? var showErrorAlert: Bool = false var hasCreated: Bool = false + + // MARK: - Clipboard Management + var clipboardURL: String? var showClipboardButton: Bool = false + // MARK: - Computed Properties + var isValid: Bool { !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)) != nil @@ -35,7 +47,6 @@ class AddBookmarkViewModel { .filter { !$0.isEmpty } } - // Computed properties for tag functionality var availableLabels: [BookmarkLabel] { return allLabels.filter { !selectedLabels.contains($0.name) } } @@ -61,6 +72,8 @@ class AddBookmarkViewModel { } } + // MARK: - Labels Management + @MainActor func loadAllLabels() async { isLabelsLoading = true @@ -108,6 +121,8 @@ class AddBookmarkViewModel { selectedLabels.remove(label) } + // MARK: - Bookmark Creation + @MainActor func createBookmark() async { guard isValid else { return } @@ -145,6 +160,8 @@ class AddBookmarkViewModel { isLoading = false } + // MARK: - Clipboard Management + func checkClipboard() { guard let clipboardString = UIPasteboard.general.string, URL(string: clipboardString) != nil else { @@ -173,6 +190,8 @@ class AddBookmarkViewModel { showClipboardButton = false } + // MARK: - Form Management + func clearForm() { url = "" title = "" diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift index 0477da1..77fcb53 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift @@ -16,145 +16,8 @@ struct BookmarkLabelsView: View { var body: some View { NavigationView { VStack(spacing: 12) { - // Add new label with search functionality - VStack(spacing: 8) { - TextField("Search or add new tag...", text: $viewModel.searchText) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .onSubmit { - Task { - await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) - } - } - - // Show custom tag suggestion if search text doesn't match existing tags - if !viewModel.searchText.isEmpty && !viewModel.filteredLabels.contains(where: { $0.name.lowercased() == viewModel.searchText.lowercased() }) { - HStack { - Text("Add new tag:") - .font(.caption) - .foregroundColor(.secondary) - Text(viewModel.searchText) - .font(.caption) - .fontWeight(.medium) - Spacer() - Button(action: { - Task { - await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) - } - }) { - HStack(spacing: 6) { - Image(systemName: "plus.circle.fill") - .font(.caption) - Text("Add") - .font(.caption) - .fontWeight(.medium) - } - } - .foregroundColor(.accentColor) - } - .padding(.horizontal, 12) - .padding(.vertical, 12) - .background(Color.accentColor.opacity(0.1)) - .cornerRadius(10) - } - } - .padding(.horizontal) - - // All available labels - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(viewModel.searchText.isEmpty ? "All available tags" : "Search results") - .font(.headline) - if !viewModel.searchText.isEmpty { - Text("(\(viewModel.filteredLabels.count) found)") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - .padding(.horizontal) - - if viewModel.isInitialLoading { - // Loading state - VStack { - ProgressView() - .scaleEffect(1.2) - .padding(.vertical, 40) - Text("Loading tags...") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .frame(height: 180) - } else if viewModel.availableLabelPages.isEmpty { - // Empty state - VStack { - Image(systemName: viewModel.searchText.isEmpty ? "tag" : "magnifyingglass") - .font(.system(size: 40)) - .foregroundColor(.secondary) - .padding(.vertical, 20) - Text(viewModel.searchText.isEmpty ? "No tags available" : "No tags found") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .frame(height: 180) - } else { - // Content state - TabView { - ForEach(Array(viewModel.availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - ForEach(labelsPage, id: \.id) { label in - UnifiedLabelChip( - label: label.name, - isSelected: viewModel.currentLabels.contains(label.name), - isRemovable: false, - onTap: { - print("addLabelsUseCase") - Task { - await viewModel.toggleLabel(for: bookmarkId, label: label.name) - } - } - ) - } - } - .frame(maxWidth: .infinity, alignment: .top) - .padding(.horizontal) - } - } - .tabViewStyle(.page(indexDisplayMode: viewModel.availableLabelPages.count > 1 ? .automatic : .never)) - .frame(height: 180) - .padding(.top, -20) - } - } - - // Current labels - if !viewModel.currentLabels.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Current tags") - .font(.headline) - .padding(.horizontal) - - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - ForEach(viewModel.currentLabels, id: \.self) { label in - UnifiedLabelChip( - label: label, - isSelected: true, - isRemovable: true, - onTap: { - // No action for current labels - }, - onRemove: { - Task { - await viewModel.removeLabel(from: bookmarkId, label: label) - } - } - ) - } - } - .padding(.horizontal) - } - } - + searchSection + availableLabelsSection Spacer() } .padding(.vertical) @@ -187,6 +50,89 @@ struct BookmarkLabelsView: View { } } } + + // MARK: - View Components + + @ViewBuilder + private var searchSection: some View { + VStack(spacing: 8) { + searchField + customTagSuggestion + } + .padding(.horizontal) + } + + @ViewBuilder + private var searchField: some View { + TextField("Search or add new tag...", text: $viewModel.searchText) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onSubmit { + Task { + await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) + } + } + } + + @ViewBuilder + private var customTagSuggestion: some View { + if !viewModel.searchText.isEmpty && + !viewModel.filteredLabels.contains(where: { $0.name.lowercased() == viewModel.searchText.lowercased() }) { + HStack { + Text("Add new tag:") + .font(.caption) + .foregroundColor(.secondary) + Text(viewModel.searchText) + .font(.caption) + .fontWeight(.medium) + Spacer() + Button(action: { + Task { + await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) + } + }) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.caption) + Text("Add") + .font(.caption) + .fontWeight(.medium) + } + } + .foregroundColor(.accentColor) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(10) + } + } + + @ViewBuilder + private var availableLabelsSection: some View { + TagManagementView( + allLabels: viewModel.allLabels, + selectedLabels: Set(viewModel.currentLabels), + searchText: $viewModel.searchText, + isLabelsLoading: viewModel.isInitialLoading, + availableLabelPages: viewModel.availableLabelPages, + filteredLabels: viewModel.filteredLabels, + onAddCustomTag: { + Task { + await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) + } + }, + onToggleLabel: { label in + Task { + await viewModel.toggleLabel(for: bookmarkId, label: label) + } + }, + onRemoveLabel: { label in + Task { + await viewModel.removeLabel(from: bookmarkId, label: label) + } + } + ) + } } #Preview { diff --git a/readeck/UI/Components/CustomTextFieldStyle.swift b/readeck/UI/Components/CustomTextFieldStyle.swift new file mode 100644 index 0000000..21688e3 --- /dev/null +++ b/readeck/UI/Components/CustomTextFieldStyle.swift @@ -0,0 +1,21 @@ +// +// CustomTextFieldStyle.swift +// readeck +// +// Created by Ilyas Hallak on 02.08.25. +// + +import SwiftUI + +struct CustomTextFieldStyle: TextFieldStyle { + func _body(configuration: TextField) -> some View { + configuration + .padding() + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(.systemGray4), lineWidth: 1) + ) + } +} diff --git a/readeck/UI/Components/TagManagementView.swift b/readeck/UI/Components/TagManagementView.swift new file mode 100644 index 0000000..1812e8a --- /dev/null +++ b/readeck/UI/Components/TagManagementView.swift @@ -0,0 +1,212 @@ +import SwiftUI + +enum AddBookmarkFieldFocus { + case url + case labels + case title +} + +struct FocusModifier: ViewModifier { + let focusBinding: FocusState.Binding? + let field: AddBookmarkFieldFocus + + func body(content: Content) -> some View { + if let binding = focusBinding { + content.focused(binding, equals: field) + } else { + content + } + } +} + +struct TagManagementView: View { + + // MARK: - Properties + + let allLabels: [BookmarkLabel] + let selectedLabelsSet: Set + let searchText: Binding + let isLabelsLoading: Bool + let availableLabelPages: [[BookmarkLabel]] + let filteredLabels: [BookmarkLabel] + let searchFieldFocus: FocusState.Binding? + + // MARK: - Callbacks + + let onAddCustomTag: () -> Void + let onToggleLabel: (String) -> Void + let onRemoveLabel: (String) -> Void + + // MARK: - Initialization + + init( + allLabels: [BookmarkLabel], + selectedLabels: Set, + searchText: Binding, + isLabelsLoading: Bool, + availableLabelPages: [[BookmarkLabel]], + filteredLabels: [BookmarkLabel], + searchFieldFocus: FocusState.Binding? = nil, + onAddCustomTag: @escaping () -> Void, + onToggleLabel: @escaping (String) -> Void, + onRemoveLabel: @escaping (String) -> Void + ) { + self.allLabels = allLabels + self.selectedLabelsSet = selectedLabels + self.searchText = searchText + self.isLabelsLoading = isLabelsLoading + self.availableLabelPages = availableLabelPages + self.filteredLabels = filteredLabels + self.searchFieldFocus = searchFieldFocus + self.onAddCustomTag = onAddCustomTag + self.onToggleLabel = onToggleLabel + self.onRemoveLabel = onRemoveLabel + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + searchField + customTagSuggestion + availableLabels + selectedLabels + } + } + + // MARK: - View Components + + @ViewBuilder + private var searchField: some View { + TextField("Search or add new tag...", text: searchText) + .textFieldStyle(CustomTextFieldStyle()) + .keyboardType(.default) + .autocorrectionDisabled(true) + .onSubmit { + onAddCustomTag() + } + .modifier(FocusModifier(focusBinding: searchFieldFocus, field: .labels)) + } + + @ViewBuilder + private var customTagSuggestion: some View { + if !searchText.wrappedValue.isEmpty && + !allLabels.contains(where: { $0.name.lowercased() == searchText.wrappedValue.lowercased() }) && + !selectedLabelsSet.contains(searchText.wrappedValue) { + HStack { + Text("Add new tag:") + .font(.caption) + .foregroundColor(.secondary) + Text(searchText.wrappedValue) + .font(.caption) + .fontWeight(.medium) + Spacer() + Button(action: onAddCustomTag) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.caption) + Text("Add") + .font(.caption) + .fontWeight(.medium) + } + } + .foregroundColor(.accentColor) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(10) + } + } + + @ViewBuilder + private var availableLabels: some View { + if !allLabels.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(searchText.wrappedValue.isEmpty ? "Available tags" : "Search results") + .font(.subheadline) + .fontWeight(.medium) + if !searchText.wrappedValue.isEmpty { + Text("(\(filteredLabels.count) found)") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + + if isLabelsLoading { + ProgressView() + .scaleEffect(0.8) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 20) + } else if availableLabelPages.isEmpty { + VStack { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.green) + Text("All tags selected") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } else { + labelsTabView + } + } + .padding(.top, 8) + } + } + + @ViewBuilder + private var labelsTabView: some View { + TabView { + ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { + ForEach(labelsPage, id: \.id) { label in + UnifiedLabelChip( + label: label.name, + isSelected: selectedLabelsSet.contains(label.name), + isRemovable: false, + onTap: { + onToggleLabel(label.name) + } + ) + } + } + .frame(maxWidth: .infinity, alignment: .top) + .padding(.horizontal) + } + } + .tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never)) + .frame(height: 180) + .padding(.top, -20) + } + + @ViewBuilder + private var selectedLabels: some View { + if !selectedLabelsSet.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Selected tags") + .font(.subheadline) + .fontWeight(.medium) + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { + ForEach(Array(selectedLabelsSet), id: \.self) { label in + UnifiedLabelChip( + label: label, + isSelected: false, + isRemovable: true, + onTap: { + // No action for selected labels + }, + onRemove: { + onRemoveLabel(label) + } + ) + } + } + } + .padding(.top, 8) + } + } +}