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" : {
}, },
"Add custom tag:" : { "Add new tag:" : {
}, },
"all" : { "all" : {
@ -73,6 +73,9 @@
}, },
"All labels selected" : { "All labels selected" : {
},
"All tags selected" : {
}, },
"Archive" : { "Archive" : {
@ -94,6 +97,9 @@
}, },
"Available labels" : { "Available labels" : {
},
"Available tags" : {
}, },
"Cancel" : { "Cancel" : {
@ -121,9 +127,6 @@
}, },
"Done" : { "Done" : {
},
"e.g. work, important, later" : {
}, },
"Enter an optional title..." : { "Enter an optional title..." : {
@ -139,9 +142,6 @@
}, },
"Favorite" : { "Favorite" : {
},
"Fertig" : {
}, },
"Finished reading?" : { "Finished reading?" : {
@ -181,9 +181,6 @@
}, },
"Key" : { "Key" : {
"extractionState" : "manual" "extractionState" : "manual"
},
"Labels" : {
}, },
"Loading %@" : { "Loading %@" : {
@ -281,9 +278,6 @@
}, },
"Remove" : { "Remove" : {
},
"Required" : {
}, },
"Reset settings" : { "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." : { "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" : { "Unarchive Bookmark" : {
},
"URL" : {
}, },
"URL in clipboard:" : { "URL in clipboard:" : {

View File

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

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UIKit
struct AddBookmarkView: View { struct AddBookmarkView: View {
@State private var viewModel = AddBookmarkViewModel() @State private var viewModel = AddBookmarkViewModel()
@ -24,18 +25,6 @@ struct AddBookmarkView: View {
VStack(spacing: 20) { VStack(spacing: 20) {
// URL Field // URL Field
VStack(alignment: .leading, spacing: 8) { 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) TextField("https://example.com", text: $viewModel.url)
.textFieldStyle(CustomTextFieldStyle()) .textFieldStyle(CustomTextFieldStyle())
.keyboardType(.URL) .keyboardType(.URL)
@ -45,7 +34,7 @@ struct AddBookmarkView: View {
viewModel.checkClipboard() viewModel.checkClipboard()
} }
// Clipboard Button // Clipboard Button - only show if we have a URL in clipboard
if viewModel.showClipboardButton { if viewModel.showClipboardButton {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@ -85,37 +74,128 @@ struct AddBookmarkView: View {
// Title Field // Title Field
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Label("Title", systemImage: "note.text")
.font(.headline)
.foregroundColor(.primary)
TextField("Optional: Custom title", text: $viewModel.title) TextField("Optional: Custom title", text: $viewModel.title)
.textFieldStyle(CustomTextFieldStyle()) .textFieldStyle(CustomTextFieldStyle())
} }
// Labels Field // Labels Field
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Label("Labels", systemImage: "tag") // Search field for tags
.font(.headline) TextField("Search or add new tag...", text: $viewModel.searchText)
.foregroundColor(.primary)
TextField("e.g. work, important, later", text: $viewModel.labelsText)
.textFieldStyle(CustomTextFieldStyle()) .textFieldStyle(CustomTextFieldStyle())
.onSubmit {
viewModel.addCustomTag()
}
// Labels Preview // Show custom tag suggestion if search text doesn't match existing tags
if !viewModel.parsedLabels.isEmpty { if !viewModel.searchText.isEmpty && !viewModel.allLabels.contains(where: { $0.name.lowercased() == viewModel.searchText.lowercased() }) && !viewModel.selectedLabels.contains(viewModel.searchText) {
LazyVGrid(columns: [ HStack {
GridItem(.adaptive(minimum: 80)) Text("Add new tag:")
], spacing: 8) { .font(.caption)
ForEach(viewModel.parsedLabels, id: \.self) { label in .foregroundColor(.secondary)
Text(label) Text(viewModel.searchText)
.font(.caption) .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) .fontWeight(.medium)
.padding(.horizontal, 12) if !viewModel.searchText.isEmpty {
.padding(.vertical, 6) Text("(\(viewModel.filteredLabels.count) found)")
.background(Color.accentColor.opacity(0.1)) .font(.caption)
.foregroundColor(.accentColor) .foregroundColor(.secondary)
.clipShape(Capsule()) }
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(.top, 8)
@ -124,14 +204,12 @@ struct AddBookmarkView: View {
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
Spacer(minLength: 100) // Space for button Spacer(minLength: 120) // Space for button
} }
} }
// Bottom Action Area // Bottom Action Area
VStack(spacing: 16) { VStack(spacing: 16) {
Divider()
VStack(spacing: 12) { VStack(spacing: 12) {
// Save Button // Save Button
Button(action: { Button(action: {
@ -160,14 +238,7 @@ struct AddBookmarkView: View {
.foregroundColor(.white) .foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
} }
.disabled(!viewModel.isValid || viewModel.isLoading) .disabled(!viewModel.isValid || viewModel.isLoading)
// Cancel Button
Button("Cancel") {
dismiss()
viewModel.clearForm()
}
.foregroundColor(.secondary)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 20) .padding(.bottom, 20)
@ -189,10 +260,22 @@ struct AddBookmarkView: View {
} message: { } message: {
Text(viewModel.errorMessage ?? "Unknown error") 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 { .onAppear {
viewModel.checkClipboard() viewModel.checkClipboard()
} }
.task {
await viewModel.loadAllLabels()
}
.onDisappear { .onDisappear {
viewModel.clearForm() viewModel.clearForm()
} }

View File

@ -4,11 +4,18 @@ import UIKit
@Observable @Observable
class AddBookmarkViewModel { class AddBookmarkViewModel {
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase() private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
var url: String = "" var url: String = ""
var title: String = "" var title: String = ""
var labelsText: 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 isLoading: Bool = false
var errorMessage: String? var errorMessage: String?
var showErrorAlert: Bool = false var showErrorAlert: Bool = false
@ -28,6 +35,79 @@ class AddBookmarkViewModel {
.filter { !$0.isEmpty } .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 @MainActor
func createBookmark() async { func createBookmark() async {
guard isValid else { return } guard isValid else { return }
@ -39,7 +119,7 @@ class AddBookmarkViewModel {
do { do {
let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines) let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let labels = parsedLabels let labels = Array(selectedLabels)
let request = CreateBookmarkRequest( let request = CreateBookmarkRequest(
url: cleanURL, url: cleanURL,
@ -97,6 +177,8 @@ class AddBookmarkViewModel {
url = "" url = ""
title = "" title = ""
labelsText = "" labelsText = ""
selectedLabels.removeAll()
searchText = ""
clipboardURL = nil clipboardURL = nil
showClipboardButton = false showClipboardButton = false
} }

View File

@ -18,27 +18,13 @@ struct BookmarkLabelsView: View {
VStack(spacing: 12) { VStack(spacing: 12) {
// Add new label with search functionality // Add new label with search functionality
VStack(spacing: 8) { VStack(spacing: 8) {
HStack(spacing: 12) { TextField("Search or add new tag...", text: $viewModel.searchText)
TextField("Search or add new tag...", text: $viewModel.searchText) .textFieldStyle(RoundedBorderTextFieldStyle())
.textFieldStyle(RoundedBorderTextFieldStyle()) .onSubmit {
.onSubmit {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
}
}
Button(action: {
Task { Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) 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 // 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() }) { if !viewModel.searchText.isEmpty && !viewModel.filteredLabels.contains(where: { $0.name.lowercased() == viewModel.searchText.lowercased() }) {
@ -50,18 +36,25 @@ struct BookmarkLabelsView: View {
.font(.caption) .font(.caption)
.fontWeight(.medium) .fontWeight(.medium)
Spacer() Spacer()
Button("Add") { Button(action: {
Task { Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) 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) .foregroundColor(.accentColor)
} }
.padding(.horizontal, 8) .padding(.horizontal, 12)
.padding(.vertical, 4) .padding(.vertical, 12)
.background(Color.accentColor.opacity(0.1)) .background(Color.accentColor.opacity(0.1))
.cornerRadius(6) .cornerRadius(10)
} }
} }
.padding(.horizontal) .padding(.horizontal)