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:
parent
3aecdf9ba2
commit
61b30112ee
@ -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:" : {
|
||||
|
||||
|
||||
@ -221,14 +221,6 @@ struct ManualTagEntryView: View {
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
dismissKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onSubmit {
|
||||
addCustomTag()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user