ReadKeep/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift
Ilyas Hallak f3719fa9d4 Refactor tag management to use Core Data with configurable sorting
This commit introduces a comprehensive refactoring of the tag management
system, replacing the previous API-based approach with a Core Data-first
strategy for improved performance and offline support.

Major Changes:

Tag Management Architecture:
- Add CoreDataTagManagementView using @FetchRequest for reactive updates
- Implement cache-first sync strategy in LabelsRepository
- Create SyncTagsUseCase following Clean Architecture principles
- Add TagSortOrder enum for configurable tag sorting (by count/alphabetically)
- Mark LegacyTagManagementView as deprecated

Share Extension Improvements:
- Replace API-based tag loading with Core Data queries
- Display top 150 tags sorted by usage count
- Remove unnecessary label fetching logic
- Add "Most used tags" localized title
- Improve offline bookmark tag management

Main App Enhancements:
- Add tag sync triggers in AddBookmarkView and BookmarkLabelsView
- Implement user-configurable tag sorting in settings
- Add sort order indicator labels with localization
- Automatic UI updates via SwiftUI @FetchRequest reactivity

Settings & Configuration:
- Add TagSortOrder setting with persistence
- Refactor Settings model structure
- Add FontFamily and FontSize domain models
- Improve settings repository with tag sort order support

Use Case Layer:
- Add SyncTagsUseCase for background tag synchronization
- Update UseCaseFactory with tag sync support
- Add mock implementations for testing

Localization:
- Add German and English translations for:
  - "Most used tags"
  - "Sorted by usage count"
  - "Sorted alphabetically"

Technical Improvements:
- Batch tag updates with conflict detection
- Background sync with silent failure handling
- Reduced server load through local caching
- Better separation of concerns following Clean Architecture
2025-11-08 13:46:40 +01:00

133 lines
4.3 KiB
Swift

import Foundation
@Observable
class BookmarkLabelsViewModel {
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
private let getLabelsUseCase: PGetLabelsUseCase
private let syncTagsUseCase: PSyncTagsUseCase
var isLoading = false
var isInitialLoading = false
var errorMessage: String?
var showErrorAlert = false
var currentLabels: [String] = []
var newLabelText = ""
var searchText = ""
var allLabels: [BookmarkLabel] = []
var availableLabels: [BookmarkLabel] {
return allLabels.filter { !currentLabels.contains($0.name) }
}
var filteredLabels: [BookmarkLabel] {
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
self.currentLabels = initialLabels
self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase()
self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase()
self.getLabelsUseCase = factory.makeGetLabelsUseCase()
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
}
/// Triggers background sync of tags from server to Core Data
/// CoreDataTagManagementView will automatically update via @FetchRequest
@MainActor
func syncTags() async {
try? await syncTagsUseCase.execute()
}
@MainActor
func loadAllLabels() async {
isInitialLoading = true
defer { isInitialLoading = false }
do {
let labels = try await getLabelsUseCase.execute()
allLabels = labels
} catch {
errorMessage = "failed to load labels"
showErrorAlert = true
}
}
@MainActor
func addLabels(to bookmarkId: String, labels: [String]) async {
isLoading = true
errorMessage = nil
do {
let uniqueLabels = Set(currentLabels + labels)
currentLabels = currentLabels.filter { uniqueLabels.contains($0) } + labels.filter { !currentLabels.contains($0) }
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
} catch let error as BookmarkUpdateError {
errorMessage = error.localizedDescription
showErrorAlert = true
} catch {
errorMessage = "Error adding labels"
showErrorAlert = true
}
isLoading = false
}
@MainActor
func addLabel(to bookmarkId: String, label: String) async {
let splitLabels = LabelUtils.splitLabelsFromInput(label)
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels)
guard !uniqueLabels.isEmpty else { return }
await addLabels(to: bookmarkId, labels: uniqueLabels)
newLabelText = ""
searchText = ""
}
@MainActor
func removeLabels(from bookmarkId: String, labels: [String]) async {
isLoading = true
errorMessage = nil
do {
try await removeLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
// Update local labels
currentLabels.removeAll { labels.contains($0) }
} catch let error as BookmarkUpdateError {
errorMessage = error.localizedDescription
showErrorAlert = true
} catch {
errorMessage = "Error removing labels"
showErrorAlert = true
}
isLoading = false
}
@MainActor
func removeLabel(from bookmarkId: String, label: String) async {
await removeLabels(from: bookmarkId, labels: [label])
}
// Convenience method für das Umschalten eines Labels (hinzufügen wenn nicht vorhanden, entfernen wenn vorhanden)
@MainActor
func toggleLabel(for bookmarkId: String, label: String) async {
if currentLabels.contains(label) {
await removeLabel(from: bookmarkId, label: label)
} else {
await addLabel(to: bookmarkId, label: label)
}
}
func updateLabels(_ labels: [String]) {
currentLabels = labels
}
}