feat: Add advanced tag functionality to AddBookmarkView

- Add tag search, pagination and selection with UnifiedLabelChip
- Implement Set-based tag management with validation
- Add keyboard toolbar with Done button
- Remove field labels for compact UI
- Fix duplicate toolbar buttons in ShareBookmarkView
- Update localization strings for tag functionality
This commit is contained in:
Ilyas Hallak 2025-07-31 23:06:35 +02:00
parent 3aecdf9ba2
commit 61b30112ee
5 changed files with 234 additions and 96 deletions

View File

@ -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:" : {

View File

@ -221,14 +221,6 @@ struct ManualTagEntryView: View {
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
dismissKeyboard()
}
}
}
.onSubmit {
addCustomTag()
}

View File

@ -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: {
@ -161,13 +239,6 @@ struct AddBookmarkView: View {
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!viewModel.isValid || viewModel.isLoading)
// Cancel Button
Button("Cancel") {
dismiss()
viewModel.clearForm()
}
.foregroundColor(.secondary)
}
.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()
}

View File

@ -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<String> = []
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..<min($0 + pageSize, labelsToShow.count)])
}
}
}
@MainActor
func loadAllLabels() async {
isLabelsLoading = true
defer { isLabelsLoading = false }
do {
let labels = try await getLabelsUseCase.execute()
allLabels = labels.sorted { $0.count > $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
}

View File

@ -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)