diff --git a/Localizable.xcstrings b/Localizable.xcstrings index ac80a58..d2dff21 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -54,7 +54,7 @@ "Add" : { }, - "Add custom tag:" : { + "Add new tag:" : { }, "all" : { @@ -73,6 +73,9 @@ }, "All labels selected" : { + }, + "All tags selected" : { + }, "Archive" : { @@ -94,6 +97,9 @@ }, "Available labels" : { + }, + "Available tags" : { + }, "Cancel" : { @@ -121,9 +127,6 @@ }, "Done" : { - }, - "e.g. work, important, later" : { - }, "Enter an optional title..." : { @@ -139,9 +142,6 @@ }, "Favorite" : { - }, - "Fertig" : { - }, "Finished reading?" : { @@ -181,9 +181,6 @@ }, "Key" : { "extractionState" : "manual" - }, - "Labels" : { - }, "Loading %@" : { @@ -281,9 +278,6 @@ }, "Remove" : { - }, - "Required" : { - }, "Reset settings" : { @@ -350,15 +344,9 @@ }, "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." : { - }, - "Title" : { - }, "Unarchive Bookmark" : { - }, - "URL" : { - }, "URL in clipboard:" : { diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift index df5b3a3..d468f65 100644 --- a/URLShare/ShareBookmarkView.swift +++ b/URLShare/ShareBookmarkView.swift @@ -221,14 +221,6 @@ struct ManualTagEntryView: View { .textFieldStyle(RoundedBorderTextFieldStyle()) .autocapitalization(.none) .disableAutocorrection(true) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - dismissKeyboard() - } - } - } .onSubmit { addCustomTag() } diff --git a/readeck/UI/AddBookmark/AddBookmarkView.swift b/readeck/UI/AddBookmark/AddBookmarkView.swift index f0a72dc..9ee939f 100644 --- a/readeck/UI/AddBookmark/AddBookmarkView.swift +++ b/readeck/UI/AddBookmark/AddBookmarkView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit struct AddBookmarkView: View { @State private var viewModel = AddBookmarkViewModel() @@ -24,18 +25,6 @@ struct AddBookmarkView: View { VStack(spacing: 20) { // URL Field VStack(alignment: .leading, spacing: 8) { - HStack { - Label("URL", systemImage: "link") - .font(.headline) - .foregroundColor(.primary) - - Spacer() - - Text("Required") - .font(.caption) - .foregroundColor(.red) - } - TextField("https://example.com", text: $viewModel.url) .textFieldStyle(CustomTextFieldStyle()) .keyboardType(.URL) @@ -45,7 +34,7 @@ struct AddBookmarkView: View { viewModel.checkClipboard() } - // Clipboard Button + // Clipboard Button - only show if we have a URL in clipboard if viewModel.showClipboardButton { HStack { VStack(alignment: .leading, spacing: 4) { @@ -85,37 +74,128 @@ struct AddBookmarkView: View { // Title Field VStack(alignment: .leading, spacing: 8) { - Label("Title", systemImage: "note.text") - .font(.headline) - .foregroundColor(.primary) - TextField("Optional: Custom title", text: $viewModel.title) .textFieldStyle(CustomTextFieldStyle()) } // Labels Field VStack(alignment: .leading, spacing: 8) { - Label("Labels", systemImage: "tag") - .font(.headline) - .foregroundColor(.primary) - - TextField("e.g. work, important, later", text: $viewModel.labelsText) + // Search field for tags + TextField("Search or add new tag...", text: $viewModel.searchText) .textFieldStyle(CustomTextFieldStyle()) + .onSubmit { + viewModel.addCustomTag() + } - // Labels Preview - if !viewModel.parsedLabels.isEmpty { - LazyVGrid(columns: [ - GridItem(.adaptive(minimum: 80)) - ], spacing: 8) { - ForEach(viewModel.parsedLabels, id: \.self) { label in - Text(label) - .font(.caption) + // 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) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.accentColor.opacity(0.1)) - .foregroundColor(.accentColor) - .clipShape(Capsule()) + 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) @@ -124,14 +204,12 @@ struct AddBookmarkView: View { } .padding(.horizontal, 20) - Spacer(minLength: 100) // Space for button + Spacer(minLength: 120) // Space for button } } // Bottom Action Area VStack(spacing: 16) { - Divider() - VStack(spacing: 12) { // Save Button Button(action: { @@ -160,14 +238,7 @@ struct AddBookmarkView: View { .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 12)) } - .disabled(!viewModel.isValid || viewModel.isLoading) - - // Cancel Button - Button("Cancel") { - dismiss() - viewModel.clearForm() - } - .foregroundColor(.secondary) + .disabled(!viewModel.isValid || viewModel.isLoading) } .padding(.horizontal, 20) .padding(.bottom, 20) @@ -189,10 +260,22 @@ struct AddBookmarkView: View { } message: { Text(viewModel.errorMessage ?? "Unknown error") } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + } + .ignoresSafeArea(.keyboard) } .onAppear { viewModel.checkClipboard() } + .task { + await viewModel.loadAllLabels() + } .onDisappear { viewModel.clearForm() } diff --git a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift index 6064bca..d84144f 100644 --- a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift +++ b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift @@ -4,11 +4,18 @@ import UIKit @Observable class AddBookmarkViewModel { private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase() + private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase() var url: String = "" var title: String = "" var labelsText: String = "" + // Tag functionality + var allLabels: [BookmarkLabel] = [] + var selectedLabels: Set = [] + var searchText: String = "" + var isLabelsLoading: Bool = false + var isLoading: Bool = false var errorMessage: String? var showErrorAlert: Bool = false @@ -28,6 +35,79 @@ class AddBookmarkViewModel { .filter { !$0.isEmpty } } + // Computed properties for tag functionality + var availableLabels: [BookmarkLabel] { + return allLabels.filter { !selectedLabels.contains($0.name) } + } + + var filteredLabels: [BookmarkLabel] { + if searchText.isEmpty { + return availableLabels + } else { + return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + } + + var availableLabelPages: [[BookmarkLabel]] { + let pageSize = Constants.Labels.pageSize + let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels + + if labelsToShow.count <= pageSize { + return [labelsToShow] + } else { + return stride(from: 0, to: labelsToShow.count, by: pageSize).map { + Array(labelsToShow[$0.. $1.count } + } catch { + errorMessage = "Failed to load labels" + showErrorAlert = true + } + } + + @MainActor + func addCustomTag() { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let lowercased = trimmed.lowercased() + let allExisting = Set(allLabels.map { $0.name.lowercased() }) + let allSelected = Set(selectedLabels.map { $0.lowercased() }) + + if allExisting.contains(lowercased) || allSelected.contains(lowercased) { + // Tag already exists, don't add + return + } else { + selectedLabels.insert(trimmed) + searchText = "" + } + } + + @MainActor + func toggleLabel(_ label: String) { + if selectedLabels.contains(label) { + selectedLabels.remove(label) + } else { + selectedLabels.insert(label) + } + searchText = "" + } + + @MainActor + func removeLabel(_ label: String) { + selectedLabels.remove(label) + } + @MainActor func createBookmark() async { guard isValid else { return } @@ -39,7 +119,7 @@ class AddBookmarkViewModel { do { let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines) let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) - let labels = parsedLabels + let labels = Array(selectedLabels) let request = CreateBookmarkRequest( url: cleanURL, @@ -97,6 +177,8 @@ class AddBookmarkViewModel { url = "" title = "" labelsText = "" + selectedLabels.removeAll() + searchText = "" clipboardURL = nil showClipboardButton = false } diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift index 1e6407c..0477da1 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift @@ -18,27 +18,13 @@ struct BookmarkLabelsView: View { VStack(spacing: 12) { // Add new label with search functionality VStack(spacing: 8) { - HStack(spacing: 12) { - TextField("Search or add new tag...", text: $viewModel.searchText) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .onSubmit { - Task { - await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) - } - } - - Button(action: { + TextField("Search or add new tag...", text: $viewModel.searchText) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onSubmit { Task { await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) } - }) { - Image(systemName: "plus.circle.fill") - .font(.title2) - .frame(width: 32, height: 32) } - .disabled(viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading) - .foregroundColor(.accentColor) - } // 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() }) { @@ -50,18 +36,25 @@ struct BookmarkLabelsView: View { .font(.caption) .fontWeight(.medium) Spacer() - Button("Add") { + 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) + } } - .font(.caption) .foregroundColor(.accentColor) } - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, 12) + .padding(.vertical, 12) .background(Color.accentColor.opacity(0.1)) - .cornerRadius(6) + .cornerRadius(10) } } .padding(.horizontal)