Refactor tag management system with improved search and real-time sync
- Add CreateLabelUseCase for consistent label creation across app and extension - Implement TagRepository for Share Extension to persist new labels to Core Data - Enhance CoreDataTagManagementView with real-time search functionality - Add automatic tag synchronization on app startup and resume - Improve Core Data context configuration for better extension support - Unify label terminology across UI components (tags -> labels) - Fix label persistence issues in Share Extension - Add immediate Core Data persistence for newly created labels - Bump version to 36
This commit is contained in:
parent
4134b41be2
commit
a3b3863fa3
@ -142,7 +142,8 @@ struct ShareBookmarkView: View {
|
|||||||
searchFieldFocus: $focusedField,
|
searchFieldFocus: $focusedField,
|
||||||
fetchLimit: 150,
|
fetchLimit: 150,
|
||||||
sortOrder: viewModel.tagSortOrder,
|
sortOrder: viewModel.tagSortOrder,
|
||||||
availableTagsTitle: "Most used tags",
|
availableLabelsTitle: "Most used labels",
|
||||||
|
context: viewContext,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
addCustomTag()
|
addCustomTag()
|
||||||
},
|
},
|
||||||
@ -200,19 +201,6 @@ struct ShareBookmarkView: View {
|
|||||||
// MARK: - Helper Functions
|
// MARK: - Helper Functions
|
||||||
|
|
||||||
private func addCustomTag() {
|
private func addCustomTag() {
|
||||||
let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText)
|
viewModel.addCustomTag(context: viewContext)
|
||||||
|
|
||||||
// Fetch available labels from Core Data
|
|
||||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
|
||||||
let availableLabels = (try? viewContext.fetch(fetchRequest))?.compactMap { $0.name } ?? []
|
|
||||||
|
|
||||||
let currentLabels = Array(viewModel.selectedLabels)
|
|
||||||
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
|
|
||||||
|
|
||||||
for label in uniqueLabels {
|
|
||||||
viewModel.selectedLabels.insert(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.searchText = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
|
|
||||||
private let logger = Logger.viewModel
|
private let logger = Logger.viewModel
|
||||||
private let serverCheck = ShareExtensionServerCheck.shared
|
private let serverCheck = ShareExtensionServerCheck.shared
|
||||||
|
private let tagRepository = TagRepository()
|
||||||
|
|
||||||
init(extensionContext: NSExtensionContext?) {
|
init(extensionContext: NSExtensionContext?) {
|
||||||
self.extensionContext = extensionContext
|
self.extensionContext = extensionContext
|
||||||
@ -149,14 +150,37 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addCustomTag(context: NSManagedObjectContext) {
|
||||||
|
let splitLabels = LabelUtils.splitLabelsFromInput(searchText)
|
||||||
|
|
||||||
|
// Fetch available labels from Core Data
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
let availableLabels = (try? context.fetch(fetchRequest))?.compactMap { $0.name } ?? []
|
||||||
|
|
||||||
|
let currentLabels = Array(selectedLabels)
|
||||||
|
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
|
||||||
|
|
||||||
|
for label in uniqueLabels {
|
||||||
|
selectedLabels.insert(label)
|
||||||
|
// Save new label to Core Data so it's available next time
|
||||||
|
tagRepository.saveNewLabel(name: label, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force refresh of @FetchRequest in CoreDataTagManagementView
|
||||||
|
// This ensures newly created labels appear immediately in the search results
|
||||||
|
context.refreshAllObjects()
|
||||||
|
|
||||||
|
searchText = ""
|
||||||
|
}
|
||||||
|
|
||||||
private func completeExtensionRequest() {
|
private func completeExtensionRequest() {
|
||||||
logger.debug("Completing extension request")
|
logger.debug("Completing extension request")
|
||||||
guard let context = extensionContext else {
|
guard let context = extensionContext else {
|
||||||
logger.warning("Extension context not available for completion")
|
logger.warning("Extension context not available for completion")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
context.completeRequest(returningItems: []) { [weak self] error in
|
context.completeRequest(returningItems: []) { [weak self] error in
|
||||||
if error {
|
if error {
|
||||||
self?.logger.error("Extension completion failed: \(error)")
|
self?.logger.error("Extension completion failed: \(error)")
|
||||||
|
|||||||
63
URLShare/TagRepository.swift
Normal file
63
URLShare/TagRepository.swift
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
/// Simple repository for managing tags in Share Extension
|
||||||
|
class TagRepository {
|
||||||
|
|
||||||
|
private let logger = Logger.data
|
||||||
|
|
||||||
|
/// Saves a new label to Core Data if it doesn't already exist
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: The label name to save
|
||||||
|
/// - context: The managed object context to use
|
||||||
|
func saveNewLabel(name: String, context: NSManagedObjectContext) {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
|
||||||
|
// Perform save in a synchronous block to ensure it completes before extension closes
|
||||||
|
context.performAndWait {
|
||||||
|
// Check if label already exists
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
let existingTags = try context.fetch(fetchRequest)
|
||||||
|
|
||||||
|
// Only create if it doesn't exist
|
||||||
|
if existingTags.isEmpty {
|
||||||
|
let newTag = TagEntity(context: context)
|
||||||
|
newTag.name = trimmedName
|
||||||
|
newTag.count = 1 // New label is being used immediately
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
logger.info("Successfully saved new label '\(trimmedName)' to Core Data")
|
||||||
|
|
||||||
|
// Force immediate persistence to disk for share extension
|
||||||
|
// Based on: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
|
||||||
|
// 1. Process pending changes
|
||||||
|
context.processPendingChanges()
|
||||||
|
|
||||||
|
// 2. Ensure persistent store coordinator writes to disk
|
||||||
|
// This is critical for extensions as they may be terminated quickly
|
||||||
|
if context.persistentStoreCoordinator != nil {
|
||||||
|
// Refresh all objects to ensure changes are pushed to store
|
||||||
|
context.refreshAllObjects()
|
||||||
|
|
||||||
|
// Reset staleness interval temporarily to force immediate persistence
|
||||||
|
let originalStalenessInterval = context.stalenessInterval
|
||||||
|
context.stalenessInterval = 0
|
||||||
|
context.refreshAllObjects()
|
||||||
|
context.stalenessInterval = originalStalenessInterval
|
||||||
|
|
||||||
|
logger.debug("Forced context refresh to ensure persistence")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug("Label '\(trimmedName)' already exists, skipping creation")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to save new label '\(trimmedName)': \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -452,7 +452,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 34;
|
CURRENT_PROJECT_VERSION = 36;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -485,7 +485,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 34;
|
CURRENT_PROJECT_VERSION = 36;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -640,7 +640,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 34;
|
CURRENT_PROJECT_VERSION = 36;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -684,7 +684,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 34;
|
CURRENT_PROJECT_VERSION = 36;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
|||||||
@ -43,6 +43,11 @@ class CoreDataManager {
|
|||||||
self?.logger.info("Core Data persistent store loaded successfully")
|
self?.logger.info("Core Data persistent store loaded successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure viewContext for better extension support
|
||||||
|
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||||
|
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||||
|
|
||||||
return container
|
return container
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@ -89,4 +89,29 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveNewLabel(name: String) async throws {
|
||||||
|
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||||
|
|
||||||
|
try await backgroundContext.perform {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
|
||||||
|
// Check if label already exists
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let existingTags = try backgroundContext.fetch(fetchRequest)
|
||||||
|
|
||||||
|
// Only create if it doesn't exist
|
||||||
|
if existingTags.isEmpty {
|
||||||
|
let newTag = TagEntity(context: backgroundContext)
|
||||||
|
newTag.name = trimmedName
|
||||||
|
newTag.count = 1 // New label is being used immediately
|
||||||
|
|
||||||
|
try backgroundContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,4 +3,5 @@ import Foundation
|
|||||||
protocol PLabelsRepository {
|
protocol PLabelsRepository {
|
||||||
func getLabels() async throws -> [BookmarkLabel]
|
func getLabels() async throws -> [BookmarkLabel]
|
||||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
|
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
|
||||||
|
func saveNewLabel(name: String) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
17
readeck/Domain/UseCase/CreateLabelUseCase.swift
Normal file
17
readeck/Domain/UseCase/CreateLabelUseCase.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PCreateLabelUseCase {
|
||||||
|
func execute(name: String) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreateLabelUseCase: PCreateLabelUseCase {
|
||||||
|
private let labelsRepository: PLabelsRepository
|
||||||
|
|
||||||
|
init(labelsRepository: PLabelsRepository) {
|
||||||
|
self.labelsRepository = labelsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(name: String) async throws {
|
||||||
|
try await labelsRepository.saveNewLabel(name: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -190,6 +190,7 @@ struct AddBookmarkView: View {
|
|||||||
searchFieldFocus: $focusedField,
|
searchFieldFocus: $focusedField,
|
||||||
fetchLimit: nil,
|
fetchLimit: nil,
|
||||||
sortOrder: appSettings.tagSortOrder,
|
sortOrder: appSettings.tagSortOrder,
|
||||||
|
context: viewContext,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
viewModel.addCustomTag()
|
viewModel.addCustomTag()
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,6 +8,7 @@ class AddBookmarkViewModel {
|
|||||||
|
|
||||||
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
|
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
|
||||||
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
|
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
|
||||||
|
private let createLabelUseCase = DefaultUseCaseFactory.shared.makeCreateLabelUseCase()
|
||||||
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
|
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
|
||||||
|
|
||||||
// MARK: - Form Data
|
// MARK: - Form Data
|
||||||
@ -87,17 +88,22 @@ class AddBookmarkViewModel {
|
|||||||
func addCustomTag() {
|
func addCustomTag() {
|
||||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
|
|
||||||
let lowercased = trimmed.lowercased()
|
let lowercased = trimmed.lowercased()
|
||||||
let allExisting = Set(allLabels.map { $0.name.lowercased() })
|
let allExisting = Set(allLabels.map { $0.name.lowercased() })
|
||||||
let allSelected = Set(selectedLabels.map { $0.lowercased() })
|
let allSelected = Set(selectedLabels.map { $0.lowercased() })
|
||||||
|
|
||||||
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
||||||
// Tag already exists, don't add
|
// Tag already exists, don't add
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
selectedLabels.insert(trimmed)
|
selectedLabels.insert(trimmed)
|
||||||
searchText = ""
|
searchText = ""
|
||||||
|
|
||||||
|
// Save new label to Core Data so it's available next time
|
||||||
|
Task {
|
||||||
|
try? await createLabelUseCase.execute(name: trimmed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,12 +13,16 @@ import SwiftUI
|
|||||||
class AppViewModel {
|
class AppViewModel {
|
||||||
private let settingsRepository = SettingsRepository()
|
private let settingsRepository = SettingsRepository()
|
||||||
private let factory: UseCaseFactory
|
private let factory: UseCaseFactory
|
||||||
|
private let syncTagsUseCase: PSyncTagsUseCase
|
||||||
|
|
||||||
var hasFinishedSetup: Bool = true
|
var hasFinishedSetup: Bool = true
|
||||||
var isServerReachable: Bool = false
|
var isServerReachable: Bool = false
|
||||||
|
|
||||||
|
private var lastAppStartTagSyncTime: Date?
|
||||||
|
|
||||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
self.factory = factory
|
self.factory = factory
|
||||||
|
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
|
||||||
setupNotificationObservers()
|
setupNotificationObservers()
|
||||||
|
|
||||||
loadSetupStatus()
|
loadSetupStatus()
|
||||||
@ -65,12 +69,29 @@ class AppViewModel {
|
|||||||
|
|
||||||
func onAppResume() async {
|
func onAppResume() async {
|
||||||
await checkServerReachability()
|
await checkServerReachability()
|
||||||
|
await syncTagsOnAppStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkServerReachability() async {
|
private func checkServerReachability() async {
|
||||||
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
|
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncTagsOnAppStart() async {
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
// Check if last sync was less than 2 minutes ago
|
||||||
|
if let lastSync = lastAppStartTagSyncTime,
|
||||||
|
now.timeIntervalSince(lastSync) < 120 {
|
||||||
|
print("AppViewModel: Skipping tag sync - last sync was less than 2 minutes ago")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync tags from server to Core Data
|
||||||
|
print("AppViewModel: Syncing tags on app start")
|
||||||
|
try? await syncTagsUseCase.execute()
|
||||||
|
lastAppStartTagSyncTime = now
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,6 +71,7 @@ struct BookmarkLabelsView: View {
|
|||||||
searchText: $viewModel.searchText,
|
searchText: $viewModel.searchText,
|
||||||
fetchLimit: nil,
|
fetchLimit: nil,
|
||||||
sortOrder: appSettings.tagSortOrder,
|
sortOrder: appSettings.tagSortOrder,
|
||||||
|
context: viewContext,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
|
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
|
||||||
|
|||||||
@ -9,7 +9,8 @@ struct CoreDataTagManagementView: View {
|
|||||||
let searchText: Binding<String>
|
let searchText: Binding<String>
|
||||||
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
||||||
let sortOrder: TagSortOrder
|
let sortOrder: TagSortOrder
|
||||||
let availableTagsTitle: String?
|
let availableLabelsTitle: String?
|
||||||
|
let context: NSManagedObjectContext
|
||||||
|
|
||||||
// MARK: - Callbacks
|
// MARK: - Callbacks
|
||||||
|
|
||||||
@ -22,6 +23,11 @@ struct CoreDataTagManagementView: View {
|
|||||||
@FetchRequest
|
@FetchRequest
|
||||||
private var tagEntities: FetchedResults<TagEntity>
|
private var tagEntities: FetchedResults<TagEntity>
|
||||||
|
|
||||||
|
// MARK: - Search State
|
||||||
|
|
||||||
|
@State private var searchResults: [TagEntity] = []
|
||||||
|
@State private var isSearchActive: Bool = false
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@ -30,7 +36,8 @@ struct CoreDataTagManagementView: View {
|
|||||||
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
||||||
fetchLimit: Int? = nil,
|
fetchLimit: Int? = nil,
|
||||||
sortOrder: TagSortOrder = .byCount,
|
sortOrder: TagSortOrder = .byCount,
|
||||||
availableTagsTitle: String? = nil,
|
availableLabelsTitle: String? = nil,
|
||||||
|
context: NSManagedObjectContext,
|
||||||
onAddCustomTag: @escaping () -> Void,
|
onAddCustomTag: @escaping () -> Void,
|
||||||
onToggleLabel: @escaping (String) -> Void,
|
onToggleLabel: @escaping (String) -> Void,
|
||||||
onRemoveLabel: @escaping (String) -> Void
|
onRemoveLabel: @escaping (String) -> Void
|
||||||
@ -39,7 +46,8 @@ struct CoreDataTagManagementView: View {
|
|||||||
self.searchText = searchText
|
self.searchText = searchText
|
||||||
self.searchFieldFocus = searchFieldFocus
|
self.searchFieldFocus = searchFieldFocus
|
||||||
self.sortOrder = sortOrder
|
self.sortOrder = sortOrder
|
||||||
self.availableTagsTitle = availableTagsTitle
|
self.availableLabelsTitle = availableLabelsTitle
|
||||||
|
self.context = context
|
||||||
self.onAddCustomTag = onAddCustomTag
|
self.onAddCustomTag = onAddCustomTag
|
||||||
self.onToggleLabel = onToggleLabel
|
self.onToggleLabel = onToggleLabel
|
||||||
self.onRemoveLabel = onRemoveLabel
|
self.onRemoveLabel = onRemoveLabel
|
||||||
@ -79,13 +87,16 @@ struct CoreDataTagManagementView: View {
|
|||||||
availableLabels
|
availableLabels
|
||||||
selectedLabels
|
selectedLabels
|
||||||
}
|
}
|
||||||
|
.onChange(of: searchText.wrappedValue) { oldValue, newValue in
|
||||||
|
performSearch(query: newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - View Components
|
// MARK: - View Components
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var searchField: some View {
|
private var searchField: some View {
|
||||||
TextField("Search or add new tag...", text: searchText)
|
TextField("Search or add new label...", text: searchText)
|
||||||
.textFieldStyle(CustomTextFieldStyle())
|
.textFieldStyle(CustomTextFieldStyle())
|
||||||
.keyboardType(.default)
|
.keyboardType(.default)
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
@ -102,7 +113,7 @@ struct CoreDataTagManagementView: View {
|
|||||||
!allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) &&
|
!allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) &&
|
||||||
!selectedLabelsSet.contains(searchText.wrappedValue) {
|
!selectedLabelsSet.contains(searchText.wrappedValue) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Add new tag:")
|
Text("Add new label:")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text(searchText.wrappedValue)
|
Text(searchText.wrappedValue)
|
||||||
@ -132,7 +143,7 @@ struct CoreDataTagManagementView: View {
|
|||||||
if !tagEntities.isEmpty {
|
if !tagEntities.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(searchText.wrappedValue.isEmpty ? (availableTagsTitle ?? "Available tags") : "Search results")
|
Text(searchText.wrappedValue.isEmpty ? (availableLabelsTitle ?? "Available labels") : "Search results")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
if !searchText.wrappedValue.isEmpty {
|
if !searchText.wrappedValue.isEmpty {
|
||||||
@ -144,16 +155,31 @@ struct CoreDataTagManagementView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if availableUnselectedTagsCount == 0 {
|
if availableUnselectedTagsCount == 0 {
|
||||||
VStack {
|
// Show "All labels selected" only if there are actually filtered results
|
||||||
Image(systemName: "checkmark.circle.fill")
|
// Otherwise show "No labels found" for empty search results
|
||||||
.font(.system(size: 24))
|
if filteredTagsCount > 0 {
|
||||||
.foregroundColor(.green)
|
VStack {
|
||||||
Text("All tags selected")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.caption)
|
.font(.system(size: 24))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.green)
|
||||||
|
Text("All labels selected")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
} else if !searchText.wrappedValue.isEmpty {
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No labels found")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 20)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 20)
|
|
||||||
} else {
|
} else {
|
||||||
labelsScrollView
|
labelsScrollView
|
||||||
}
|
}
|
||||||
@ -174,17 +200,26 @@ struct CoreDataTagManagementView: View {
|
|||||||
alignment: .top,
|
alignment: .top,
|
||||||
spacing: 8
|
spacing: 8
|
||||||
) {
|
) {
|
||||||
ForEach(tagEntities, id: \.objectID) { entity in
|
// Use searchResults when search is active, otherwise use tagEntities
|
||||||
if let name = entity.name, shouldShowTag(name) {
|
let tagsToDisplay = isSearchActive ? searchResults : Array(tagEntities)
|
||||||
UnifiedLabelChip(
|
|
||||||
label: name,
|
ForEach(tagsToDisplay, id: \.objectID) { entity in
|
||||||
isSelected: false,
|
if let name = entity.name {
|
||||||
isRemovable: false,
|
// When searching, show all results (already filtered by predicate)
|
||||||
onTap: {
|
// When not searching, filter with shouldShowTag()
|
||||||
onToggleLabel(name)
|
let shouldShow = isSearchActive ? !selectedLabelsSet.contains(name) : shouldShowTag(name)
|
||||||
}
|
|
||||||
)
|
if shouldShow {
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
UnifiedLabelChip(
|
||||||
|
label: name,
|
||||||
|
isSelected: false,
|
||||||
|
isRemovable: false,
|
||||||
|
onTap: {
|
||||||
|
onToggleLabel(name)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -200,7 +235,9 @@ struct CoreDataTagManagementView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var filteredTagsCount: Int {
|
private var filteredTagsCount: Int {
|
||||||
if searchText.wrappedValue.isEmpty {
|
if isSearchActive {
|
||||||
|
return searchResults.count
|
||||||
|
} else if searchText.wrappedValue.isEmpty {
|
||||||
return tagEntities.count
|
return tagEntities.count
|
||||||
} else {
|
} else {
|
||||||
return tagEntities.filter { entity in
|
return tagEntities.filter { entity in
|
||||||
@ -211,12 +248,19 @@ struct CoreDataTagManagementView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var availableUnselectedTagsCount: Int {
|
private var availableUnselectedTagsCount: Int {
|
||||||
tagEntities.filter { entity in
|
if isSearchActive {
|
||||||
guard let name = entity.name else { return false }
|
return searchResults.filter { entity in
|
||||||
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
|
guard let name = entity.name else { return false }
|
||||||
let isNotSelected = !selectedLabelsSet.contains(name)
|
return !selectedLabelsSet.contains(name)
|
||||||
return matchesSearch && isNotSelected
|
}.count
|
||||||
}.count
|
} else {
|
||||||
|
return tagEntities.filter { entity in
|
||||||
|
guard let name = entity.name else { return false }
|
||||||
|
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
|
||||||
|
let isNotSelected = !selectedLabelsSet.contains(name)
|
||||||
|
return matchesSearch && isNotSelected
|
||||||
|
}.count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldShowTag(_ name: String) -> Bool {
|
private func shouldShowTag(_ name: String) -> Bool {
|
||||||
@ -225,11 +269,42 @@ struct CoreDataTagManagementView: View {
|
|||||||
return matchesSearch && isNotSelected
|
return matchesSearch && isNotSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func performSearch(query: String) {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
isSearchActive = false
|
||||||
|
searchResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search directly in Core Data without fetchLimit
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", query)
|
||||||
|
|
||||||
|
// Use same sort order as main fetch
|
||||||
|
let sortDescriptors: [NSSortDescriptor]
|
||||||
|
switch sortOrder {
|
||||||
|
case .byCount:
|
||||||
|
sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \TagEntity.count, ascending: false),
|
||||||
|
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
|
||||||
|
]
|
||||||
|
case .alphabetically:
|
||||||
|
sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
fetchRequest.sortDescriptors = sortDescriptors
|
||||||
|
|
||||||
|
// NO fetchLimit - search ALL tags in database
|
||||||
|
searchResults = (try? context.fetch(fetchRequest)) ?? []
|
||||||
|
isSearchActive = true
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var selectedLabels: some View {
|
private var selectedLabels: some View {
|
||||||
if !selectedLabelsSet.isEmpty {
|
if !selectedLabelsSet.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Selected tags")
|
Text("Selected labels")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ protocol UseCaseFactory {
|
|||||||
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
|
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
|
||||||
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
||||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||||
|
func makeCreateLabelUseCase() -> PCreateLabelUseCase
|
||||||
func makeSyncTagsUseCase() -> PSyncTagsUseCase
|
func makeSyncTagsUseCase() -> PSyncTagsUseCase
|
||||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||||
@ -104,6 +105,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeCreateLabelUseCase() -> PCreateLabelUseCase {
|
||||||
|
let api = API(tokenProvider: KeychainTokenProvider())
|
||||||
|
let labelsRepository = LabelsRepository(api: api)
|
||||||
|
return CreateLabelUseCase(labelsRepository: labelsRepository)
|
||||||
|
}
|
||||||
|
|
||||||
func makeSyncTagsUseCase() -> PSyncTagsUseCase {
|
func makeSyncTagsUseCase() -> PSyncTagsUseCase {
|
||||||
let api = API(tokenProvider: KeychainTokenProvider())
|
let api = API(tokenProvider: KeychainTokenProvider())
|
||||||
let labelsRepository = LabelsRepository(api: api)
|
let labelsRepository = LabelsRepository(api: api)
|
||||||
|
|||||||
@ -77,6 +77,10 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
MockGetLabelsUseCase()
|
MockGetLabelsUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeCreateLabelUseCase() -> any PCreateLabelUseCase {
|
||||||
|
MockCreateLabelUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
|
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
|
||||||
MockSyncTagsUseCase()
|
MockSyncTagsUseCase()
|
||||||
}
|
}
|
||||||
@ -129,6 +133,12 @@ class MockGetLabelsUseCase: PGetLabelsUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockCreateLabelUseCase: PCreateLabelUseCase {
|
||||||
|
func execute(name: String) async throws {
|
||||||
|
// Mock implementation - does nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MockSyncTagsUseCase: PSyncTagsUseCase {
|
class MockSyncTagsUseCase: PSyncTagsUseCase {
|
||||||
func execute() async throws {
|
func execute() async throws {
|
||||||
// Mock implementation - does nothing
|
// Mock implementation - does nothing
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user