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
This commit is contained in:
Ilyas Hallak 2025-11-08 13:46:40 +01:00
parent 460b05ef34
commit f3719fa9d4
31 changed files with 747 additions and 264 deletions

View File

@ -84,6 +84,7 @@ class OfflineBookmarkManager: @unchecked Sendable {
if !existingNames.contains(tag) { if !existingNames.contains(tag) {
let entity = TagEntity(context: backgroundContext) let entity = TagEntity(context: backgroundContext)
entity.name = tag entity.name = tag
entity.count = 0
insertCount += 1 insertCount += 1
} }
} }
@ -98,5 +99,52 @@ class OfflineBookmarkManager: @unchecked Sendable {
print("Failed to save tags: \(error)") print("Failed to save tags: \(error)")
} }
} }
func saveTagsWithCount(_ tags: [BookmarkLabelDto]) async {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
try await backgroundContext.perform {
// Batch fetch existing tags
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
var existingByName: [String: TagEntity] = [:]
for entity in existingEntities {
if let name = entity.name {
existingByName[name] = entity
}
}
// Insert or update tags
var insertCount = 0
var updateCount = 0
for tag in tags {
if let existing = existingByName[tag.name] {
// Update count if changed
if existing.count != tag.count {
existing.count = Int32(tag.count)
updateCount += 1
}
} else {
// Insert new tag
let entity = TagEntity(context: backgroundContext)
entity.name = tag.name
entity.count = Int32(tag.count)
insertCount += 1
}
}
// Only save if there are changes
if insertCount > 0 || updateCount > 0 {
try backgroundContext.save()
print("Saved \(insertCount) new tags and updated \(updateCount) tags to Core Data")
}
}
} catch {
print("Failed to save tags with count: \(error)")
}
}
} }

View File

@ -1,9 +1,12 @@
import SwiftUI import SwiftUI
import CoreData
struct ShareBookmarkView: View { struct ShareBookmarkView: View {
@ObservedObject var viewModel: ShareBookmarkViewModel @ObservedObject var viewModel: ShareBookmarkViewModel
@State private var keyboardHeight: CGFloat = 0 @State private var keyboardHeight: CGFloat = 0
@FocusState private var focusedField: AddBookmarkFieldFocus? @FocusState private var focusedField: AddBookmarkFieldFocus?
@Environment(\.managedObjectContext) private var viewContext
private func dismissKeyboard() { private func dismissKeyboard() {
NotificationCenter.default.post(name: .dismissKeyboard, object: nil) NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
@ -39,7 +42,6 @@ struct ShareBookmarkView: View {
saveButtonSection saveButtonSection
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.onAppear { viewModel.onAppear() }
.ignoresSafeArea(.keyboard, edges: .bottom) .ignoresSafeArea(.keyboard, edges: .bottom)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
@ -134,32 +136,30 @@ struct ShareBookmarkView: View {
@ViewBuilder @ViewBuilder
private var tagManagementSection: some View { private var tagManagementSection: some View {
if !viewModel.labels.isEmpty || !viewModel.isServerReachable { CoreDataTagManagementView(
TagManagementView( selectedLabels: viewModel.selectedLabels,
allLabels: convertToBookmarkLabels(viewModel.labels), searchText: $viewModel.searchText,
selectedLabels: viewModel.selectedLabels, searchFieldFocus: $focusedField,
searchText: $viewModel.searchText, fetchLimit: 150,
isLabelsLoading: false, sortOrder: viewModel.tagSortOrder,
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels), availableTagsTitle: "Most used tags",
searchFieldFocus: $focusedField, onAddCustomTag: {
onAddCustomTag: { addCustomTag()
addCustomTag() },
}, onToggleLabel: { label in
onToggleLabel: { label in if viewModel.selectedLabels.contains(label) {
if viewModel.selectedLabels.contains(label) {
viewModel.selectedLabels.remove(label)
} else {
viewModel.selectedLabels.insert(label)
}
viewModel.searchText = ""
},
onRemoveLabel: { label in
viewModel.selectedLabels.remove(label) viewModel.selectedLabels.remove(label)
} else {
viewModel.selectedLabels.insert(label)
} }
) viewModel.searchText = ""
.padding(.top, 20) },
.padding(.horizontal, 16) onRemoveLabel: { label in
} viewModel.selectedLabels.remove(label)
}
)
.padding(.top, 20)
.padding(.horizontal, 16)
} }
@ViewBuilder @ViewBuilder
@ -198,25 +198,21 @@ struct ShareBookmarkView: View {
} }
// MARK: - Helper Functions // MARK: - Helper Functions
private func convertToBookmarkLabels(_ dtos: [BookmarkLabelDto]) -> [BookmarkLabel] {
return dtos.map { .init(name: $0.name, count: $0.count, href: $0.href) }
}
private func convertToBookmarkLabelPages(_ dtoPages: [[BookmarkLabelDto]]) -> [[BookmarkLabel]] {
return dtoPages.map { convertToBookmarkLabels($0) }
}
private func addCustomTag() { private func addCustomTag() {
let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText) let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText)
let availableLabels = viewModel.labels.map { $0.name }
// 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 currentLabels = Array(viewModel.selectedLabels)
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels) let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
for label in uniqueLabels { for label in uniqueLabels {
viewModel.selectedLabels.insert(label) viewModel.selectedLabels.insert(label)
} }
viewModel.searchText = "" viewModel.searchText = ""
} }
} }

View File

@ -6,54 +6,23 @@ import CoreData
class ShareBookmarkViewModel: ObservableObject { class ShareBookmarkViewModel: ObservableObject {
@Published var url: String? @Published var url: String?
@Published var title: String = "" @Published var title: String = ""
@Published var labels: [BookmarkLabelDto] = []
@Published var selectedLabels: Set<String> = [] @Published var selectedLabels: Set<String> = []
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil @Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
@Published var isSaving: Bool = false @Published var isSaving: Bool = false
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var isServerReachable: Bool = true @Published var isServerReachable: Bool = true
let tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount
let extensionContext: NSExtensionContext? let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel private let logger = Logger.viewModel
private let serverCheck = ShareExtensionServerCheck.shared private let serverCheck = ShareExtensionServerCheck.shared
var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) }
}
// filtered labels based on search text
var filteredLabels: [BookmarkLabelDto] {
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var availableLabelPages: [[BookmarkLabelDto]] {
let pageSize = 12 // Extension can't access 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)])
}
}
}
init(extensionContext: NSExtensionContext?) { init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext self.extensionContext = extensionContext
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)") logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
extractSharedContent() extractSharedContent()
} }
func onAppear() {
logger.debug("ShareBookmarkViewModel appeared")
loadLabels()
}
private func extractSharedContent() { private func extractSharedContent() {
logger.debug("Starting to extract shared content") logger.debug("Starting to extract shared content")
guard let extensionContext = extensionContext else { guard let extensionContext = extensionContext else {
@ -119,52 +88,7 @@ class ShareBookmarkViewModel: ObservableObject {
} }
} }
} }
func loadLabels() {
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
logger.debug("Starting to load labels")
Task {
// 1. First, load from Core Data (instant response)
let localTags = await OfflineBookmarkManager.shared.getTags()
let localLabels = localTags.enumerated().map { index, tagName in
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
}
await MainActor.run {
self.labels = localLabels
self.logger.info("Loaded \(localLabels.count) labels from local cache")
}
// 2. Then check server and sync in background
let serverReachable = await serverCheck.checkServerReachability()
await MainActor.run {
self.isServerReachable = serverReachable
}
logger.debug("Server reachable for labels: \(serverReachable)")
if serverReachable {
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
if error {
self?.logger.error("Failed to sync labels from API: \(message)")
}
} ?? []
// Save new labels to Core Data
let tagNames = loaded.map { $0.name }
await OfflineBookmarkManager.shared.saveTags(tagNames)
let sorted = loaded.sorted { $0.count > $1.count }
await MainActor.run {
self.labels = Array(sorted)
self.logger.info("Synced \(loaded.count) labels from API and updated cache")
measurement.end()
}
} else {
measurement.end()
}
}
}
func save() { func save() {
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)") logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
guard let url = url, !url.isEmpty else { guard let url = url, !url.isEmpty else {
@ -205,19 +129,23 @@ class ShareBookmarkViewModel: ObservableObject {
) )
logger.info("Local save result: \(success)") logger.info("Local save result: \(success)")
DispatchQueue.main.async { await MainActor.run {
self.isSaving = false self.isSaving = false
if success { if success {
self.logger.info("Bookmark saved locally successfully") self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.completeExtensionRequest()
}
} else { } else {
self.logger.error("Failed to save bookmark locally") self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "") self.statusMessage = ("Failed to save locally.", true, "")
} }
} }
if success {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await MainActor.run {
self.completeExtensionRequest()
}
}
} }
} }
} }

View File

@ -11,14 +11,15 @@ import UniformTypeIdentifiers
import SwiftUI import SwiftUI
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
private var hostingController: UIHostingController<ShareBookmarkView>? private var hostingController: UIHostingController<AnyView>?
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext) let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext)
let swiftUIView = ShareBookmarkView(viewModel: viewModel) let swiftUIView = ShareBookmarkView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: swiftUIView) .environment(\.managedObjectContext, CoreDataManager.shared.context)
let hostingController = UIHostingController(rootView: AnyView(swiftUIView))
addChild(hostingController) addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view) view.addSubview(hostingController.view)

View File

@ -87,12 +87,22 @@
Data/Utils/LabelUtils.swift, Data/Utils/LabelUtils.swift,
Domain/Model/Bookmark.swift, Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift, Domain/Model/BookmarkLabel.swift,
Domain/Model/CardLayoutStyle.swift,
Domain/Model/FontFamily.swift,
Domain/Model/FontSize.swift,
Domain/Model/Settings.swift,
Domain/Model/TagSortOrder.swift,
Domain/Model/Theme.swift,
Domain/Model/UrlOpener.swift,
readeck.xcdatamodeld, readeck.xcdatamodeld,
Splash.storyboard, Splash.storyboard,
UI/Components/Constants.swift, UI/Components/Constants.swift,
UI/Components/CoreDataTagManagementView.swift,
UI/Components/CustomTextFieldStyle.swift, UI/Components/CustomTextFieldStyle.swift,
UI/Components/TagManagementView.swift, UI/Components/LegacyTagManagementView.swift,
UI/Components/UnifiedLabelChip.swift, UI/Components/UnifiedLabelChip.swift,
UI/Extension/FontSizeExtension.swift,
UI/Models/AppSettings.swift,
UI/Utils/NotificationNames.swift, UI/Utils/NotificationNames.swift,
Utils/Logger.swift, Utils/Logger.swift,
Utils/LogStore.swift, Utils/LogStore.swift,

View File

@ -9,11 +9,12 @@ import Foundation
import CoreData import CoreData
extension BookmarkLabelDto { extension BookmarkLabelDto {
@discardableResult @discardableResult
func toEntity(context: NSManagedObjectContext) -> TagEntity { func toEntity(context: NSManagedObjectContext) -> TagEntity {
let entity = TagEntity(context: context) let entity = TagEntity(context: context)
entity.name = name entity.name = name
entity.count = Int32(count)
return entity return entity
} }
} }

View File

@ -33,14 +33,17 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
return try await backgroundContext.perform { return try await backgroundContext.perform {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest() let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "count", ascending: false),
NSSortDescriptor(key: "name", ascending: true)
]
let entities = try backgroundContext.fetch(fetchRequest) let entities = try backgroundContext.fetch(fetchRequest)
return entities.compactMap { entity -> BookmarkLabel? in return entities.compactMap { entity -> BookmarkLabel? in
guard let name = entity.name, !name.isEmpty else { return nil } guard let name = entity.name, !name.isEmpty else { return nil }
return BookmarkLabel( return BookmarkLabel(
name: name, name: name,
count: 0, count: Int(entity.count),
href: name href: name
) )
} }
@ -51,24 +54,37 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
let backgroundContext = coreDataManager.newBackgroundContext() let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform { try await backgroundContext.perform {
// Batch fetch all existing label names (much faster than individual queries) // Batch fetch all existing labels
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest() let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"] fetchRequest.propertiesToFetch = ["name", "count"]
let existingEntities = try backgroundContext.fetch(fetchRequest) let existingEntities = try backgroundContext.fetch(fetchRequest)
let existingNames = Set(existingEntities.compactMap { $0.name }) var existingByName: [String: TagEntity] = [:]
for entity in existingEntities {
if let name = entity.name {
existingByName[name] = entity
}
}
// Only insert new labels // Insert or update labels
var insertCount = 0 var insertCount = 0
var updateCount = 0
for dto in dtos { for dto in dtos {
if !existingNames.contains(dto.name) { if let existing = existingByName[dto.name] {
// Update count if changed
if existing.count != dto.count {
existing.count = Int32(dto.count)
updateCount += 1
}
} else {
// Insert new label
dto.toEntity(context: backgroundContext) dto.toEntity(context: backgroundContext)
insertCount += 1 insertCount += 1
} }
} }
// Only save if there are new labels // Only save if there are changes
if insertCount > 0 { if insertCount > 0 || updateCount > 0 {
try backgroundContext.save() try backgroundContext.save()
} }
} }

View File

@ -1,30 +1,6 @@
import Foundation import Foundation
import CoreData import CoreData
struct Settings {
var endpoint: String? = nil
var username: String? = nil
var password: String? = nil
var token: String? = nil
var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var urlOpener: UrlOpener? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
}
mutating func setToken(_ newToken: String) {
token = newToken
}
}
protocol PSettingsRepository { protocol PSettingsRepository {
func saveSettings(_ settings: Settings) async throws func saveSettings(_ settings: Settings) async throws
func loadSettings() async throws -> Settings? func loadSettings() async throws -> Settings?
@ -33,9 +9,11 @@ protocol PSettingsRepository {
func saveUsername(_ username: String) async throws func saveUsername(_ username: String) async throws
func savePassword(_ password: String) async throws func savePassword(_ password: String) async throws
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
func loadCardLayoutStyle() async throws -> CardLayoutStyle func loadCardLayoutStyle() async throws -> CardLayoutStyle
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
func loadTagSortOrder() async throws -> TagSortOrder
var hasFinishedSetup: Bool { get } var hasFinishedSetup: Bool { get }
} }
@ -100,7 +78,11 @@ class SettingsRepository: PSettingsRepository {
if let cardLayoutStyle = settings.cardLayoutStyle { if let cardLayoutStyle = settings.cardLayoutStyle {
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
} }
if let tagSortOrder = settings.tagSortOrder {
existingSettings.tagSortOrder = tagSortOrder.rawValue
}
try context.save() try context.save()
continuation.resume() continuation.resume()
} catch { } catch {
@ -139,6 +121,7 @@ class SettingsRepository: PSettingsRepository {
enableTTS: settingEntity?.enableTTS, enableTTS: settingEntity?.enableTTS,
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue), theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue), cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue),
tagSortOrder: TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue),
urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue) urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue)
) )
continuation.resume(returning: settings) continuation.resume(returning: settings)
@ -244,16 +227,16 @@ class SettingsRepository: PSettingsRepository {
func loadCardLayoutStyle() async throws -> CardLayoutStyle { func loadCardLayoutStyle() async throws -> CardLayoutStyle {
let context = coreDataManager.context let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
context.perform { context.perform {
do { do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest() let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1 fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest) let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first let settingEntity = settingEntities.first
let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine
continuation.resume(returning: cardLayoutStyle) continuation.resume(returning: cardLayoutStyle)
} catch { } catch {
@ -262,4 +245,45 @@ class SettingsRepository: PSettingsRepository {
} }
} }
} }
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
existingSettings.tagSortOrder = tagSortOrder.rawValue
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func loadTagSortOrder() async throws -> TagSortOrder {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first
let tagSortOrder = TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue) ?? .byCount
continuation.resume(returning: tagSortOrder)
} catch {
continuation.resume(throwing: error)
}
}
}
}
} }

View File

@ -0,0 +1,23 @@
//
// FontFamily.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}

View File

@ -0,0 +1,33 @@
//
// FontSize.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
import Foundation
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
}

View File

@ -0,0 +1,32 @@
//
// Settings.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
struct Settings {
var endpoint: String? = nil
var username: String? = nil
var password: String? = nil
var token: String? = nil
var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var tagSortOrder: TagSortOrder? = nil
var urlOpener: UrlOpener? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
}
mutating func setToken(_ newToken: String) {
token = newToken
}
}

View File

@ -0,0 +1,20 @@
//
// TagSortOrder.swift
// readeck
//
// Created by Ilyas Hallak
//
import Foundation
enum TagSortOrder: String, CaseIterable {
case byCount = "count"
case alphabetically = "alphabetically"
var displayName: String {
switch self {
case .byCount: return "By usage count"
case .alphabetically: return "Alphabetically"
}
}
}

View File

@ -1,3 +1,10 @@
//
// UrlOpener.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
enum UrlOpener: String, CaseIterable { enum UrlOpener: String, CaseIterable {
case inAppBrowser = "inAppBrowser" case inAppBrowser = "inAppBrowser"
case defaultBrowser = "defaultBrowser" case defaultBrowser = "defaultBrowser"

View File

@ -0,0 +1,21 @@
import Foundation
protocol PSyncTagsUseCase {
func execute() async throws
}
/// Triggers background synchronization of tags from server to Core Data
/// Uses cache-first strategy - returns immediately after triggering sync
class SyncTagsUseCase: PSyncTagsUseCase {
private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) {
self.labelsRepository = labelsRepository
}
func execute() async throws {
// Trigger the sync - getLabels() uses cache-first + background sync strategy
// We don't need the return value, just triggering the sync is enough
_ = try await labelsRepository.getLabels()
}
}

View File

@ -59,6 +59,9 @@
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Dieses Lesezeichen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."; "Are you sure you want to delete this bookmark? This action cannot be undone." = "Dieses Lesezeichen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Wirklich abmelden? Dies löscht alle Anmeldedaten und führt zurück zur Einrichtung."; "Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Wirklich abmelden? Dies löscht alle Anmeldedaten und führt zurück zur Einrichtung.";
"Available tags" = "Verfügbare Labels"; "Available tags" = "Verfügbare Labels";
"Most used tags" = "Meist verwendete Labels";
"Sorted by usage count" = "Sortiert nach Verwendungshäufigkeit";
"Sorted alphabetically" = "Alphabetisch sortiert";
"Cancel" = "Abbrechen"; "Cancel" = "Abbrechen";
"Category-specific Levels" = "Kategorie-spezifische Level"; "Category-specific Levels" = "Kategorie-spezifische Level";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Änderungen werden sofort wirksam. Niedrigere Log-Level enthalten höhere (Debug enthält alle, Critical nur kritische Nachrichten)."; "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Änderungen werden sofort wirksam. Niedrigere Log-Level enthalten höhere (Debug enthält alle, Critical nur kritische Nachrichten).";

View File

@ -55,6 +55,9 @@
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone."; "Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup."; "Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
"Available tags" = "Available tags"; "Available tags" = "Available tags";
"Most used tags" = "Most used tags";
"Sorted by usage count" = "Sorted by usage count";
"Sorted alphabetically" = "Sorted alphabetically";
"Cancel" = "Cancel"; "Cancel" = "Cancel";
"Category-specific Levels" = "Category-specific Levels"; "Category-specific Levels" = "Category-specific Levels";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)."; "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";

View File

@ -4,6 +4,8 @@ import UIKit
struct AddBookmarkView: View { struct AddBookmarkView: View {
@State private var viewModel = AddBookmarkViewModel() @State private var viewModel = AddBookmarkViewModel()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject private var appSettings: AppSettings
@FocusState private var focusedField: AddBookmarkFieldFocus? @FocusState private var focusedField: AddBookmarkFieldFocus?
@State private var keyboardHeight: CGFloat = 0 @State private var keyboardHeight: CGFloat = 0
@ -58,9 +60,9 @@ struct AddBookmarkView: View {
} }
.onAppear { .onAppear {
viewModel.checkClipboard() viewModel.checkClipboard()
} Task {
.task { await viewModel.syncTags()
await viewModel.loadAllLabels() }
} }
.onDisappear { .onDisappear {
viewModel.clearForm() viewModel.clearForm()
@ -177,23 +179,28 @@ struct AddBookmarkView: View {
@ViewBuilder @ViewBuilder
private var labelsField: some View { private var labelsField: some View {
TagManagementView( VStack(alignment: .leading, spacing: 8) {
allLabels: viewModel.allLabels, Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
selectedLabels: viewModel.selectedLabels, .font(.caption)
searchText: $viewModel.searchText, .foregroundColor(.secondary)
isLabelsLoading: viewModel.isLabelsLoading,
filteredLabels: viewModel.filteredLabels, CoreDataTagManagementView(
searchFieldFocus: $focusedField, selectedLabels: viewModel.selectedLabels,
onAddCustomTag: { searchText: $viewModel.searchText,
viewModel.addCustomTag() searchFieldFocus: $focusedField,
}, fetchLimit: nil,
onToggleLabel: { label in sortOrder: appSettings.tagSortOrder,
viewModel.toggleLabel(label) onAddCustomTag: {
}, viewModel.addCustomTag()
onRemoveLabel: { label in },
viewModel.removeLabel(label) onToggleLabel: { label in
} viewModel.toggleLabel(label)
) },
onRemoveLabel: { label in
viewModel.removeLabel(label)
}
)
}
} }
@ViewBuilder @ViewBuilder

View File

@ -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 syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
// MARK: - Form Data // MARK: - Form Data
var url: String = "" var url: String = ""
@ -60,12 +61,19 @@ class AddBookmarkViewModel {
} }
// MARK: - Labels Management // MARK: - Labels Management
/// 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 @MainActor
func loadAllLabels() async { func loadAllLabels() async {
isLabelsLoading = true isLabelsLoading = true
defer { isLabelsLoading = false } defer { isLabelsLoading = false }
do { do {
let labels = try await getLabelsUseCase.execute() let labels = try await getLabelsUseCase.execute()
allLabels = labels.sorted { $0.count > $1.count } allLabels = labels.sorted { $0.count > $1.count }

View File

@ -4,6 +4,8 @@ struct BookmarkLabelsView: View {
let bookmarkId: String let bookmarkId: String
@State private var viewModel: BookmarkLabelsViewModel @State private var viewModel: BookmarkLabelsViewModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject private var appSettings: AppSettings
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) { init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) {
self.bookmarkId = bookmarkId self.bookmarkId = bookmarkId
@ -40,13 +42,15 @@ struct BookmarkLabelsView: View {
} message: { } message: {
Text(viewModel.errorMessage ?? "Unknown error") Text(viewModel.errorMessage ?? "Unknown error")
} }
.task {
await viewModel.loadAllLabels()
}
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.onTapGesture { .onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
} }
.onAppear {
Task {
await viewModel.syncTags()
}
}
} }
} }
@ -56,29 +60,35 @@ struct BookmarkLabelsView: View {
@ViewBuilder @ViewBuilder
private var availableLabelsSection: some View { private var availableLabelsSection: some View {
TagManagementView( VStack(alignment: .leading, spacing: 8) {
allLabels: viewModel.allLabels, Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
selectedLabels: Set(viewModel.currentLabels), .font(.caption)
searchText: $viewModel.searchText, .foregroundColor(.secondary)
isLabelsLoading: viewModel.isInitialLoading, .padding(.horizontal)
filteredLabels: viewModel.filteredLabels,
onAddCustomTag: { CoreDataTagManagementView(
Task { selectedLabels: Set(viewModel.currentLabels),
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText) searchText: $viewModel.searchText,
fetchLimit: nil,
sortOrder: appSettings.tagSortOrder,
onAddCustomTag: {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
}
},
onToggleLabel: { label in
Task {
await viewModel.toggleLabel(for: bookmarkId, label: label)
}
},
onRemoveLabel: { label in
Task {
await viewModel.removeLabel(from: bookmarkId, label: label)
}
} }
}, )
onToggleLabel: { label in .padding(.horizontal)
Task { }
await viewModel.toggleLabel(for: bookmarkId, label: label)
}
},
onRemoveLabel: { label in
Task {
await viewModel.removeLabel(from: bookmarkId, label: label)
}
}
)
.padding(.horizontal)
} }
} }

View File

@ -5,6 +5,7 @@ class BookmarkLabelsViewModel {
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
private let getLabelsUseCase: PGetLabelsUseCase private let getLabelsUseCase: PGetLabelsUseCase
private let syncTagsUseCase: PSyncTagsUseCase
var isLoading = false var isLoading = false
var isInitialLoading = false var isInitialLoading = false
@ -30,13 +31,20 @@ class BookmarkLabelsViewModel {
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
self.currentLabels = initialLabels self.currentLabels = initialLabels
self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase() self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase()
self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase() self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase()
self.getLabelsUseCase = factory.makeGetLabelsUseCase() 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 @MainActor
func loadAllLabels() async { func loadAllLabels() async {
isInitialLoading = true isInitialLoading = true

View File

@ -0,0 +1,255 @@
import SwiftUI
import CoreData
struct CoreDataTagManagementView: View {
// MARK: - Properties
let selectedLabelsSet: Set<String>
let searchText: Binding<String>
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
let sortOrder: TagSortOrder
let availableTagsTitle: String?
// MARK: - Callbacks
let onAddCustomTag: () -> Void
let onToggleLabel: (String) -> Void
let onRemoveLabel: (String) -> Void
// MARK: - FetchRequest
@FetchRequest
private var tagEntities: FetchedResults<TagEntity>
// MARK: - Initialization
init(
selectedLabels: Set<String>,
searchText: Binding<String>,
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
fetchLimit: Int? = nil,
sortOrder: TagSortOrder = .byCount,
availableTagsTitle: String? = nil,
onAddCustomTag: @escaping () -> Void,
onToggleLabel: @escaping (String) -> Void,
onRemoveLabel: @escaping (String) -> Void
) {
self.selectedLabelsSet = selectedLabels
self.searchText = searchText
self.searchFieldFocus = searchFieldFocus
self.sortOrder = sortOrder
self.availableTagsTitle = availableTagsTitle
self.onAddCustomTag = onAddCustomTag
self.onToggleLabel = onToggleLabel
self.onRemoveLabel = onRemoveLabel
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
// Apply sort order from parameter
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
if let limit = fetchLimit {
fetchRequest.fetchLimit = limit
}
fetchRequest.fetchBatchSize = 20
_tagEntities = FetchRequest(
fetchRequest: fetchRequest,
animation: .default
)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
searchField
customTagSuggestion
availableLabels
selectedLabels
}
}
// MARK: - View Components
@ViewBuilder
private var searchField: some View {
TextField("Search or add new tag...", text: searchText)
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.default)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onSubmit {
onAddCustomTag()
}
.modifier(FocusModifier(focusBinding: searchFieldFocus, field: .labels))
}
@ViewBuilder
private var customTagSuggestion: some View {
if !searchText.wrappedValue.isEmpty &&
!allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) &&
!selectedLabelsSet.contains(searchText.wrappedValue) {
HStack {
Text("Add new tag:")
.font(.subheadline)
.foregroundColor(.secondary)
Text(searchText.wrappedValue)
.font(.subheadline)
.fontWeight(.medium)
Spacer()
Button(action: onAddCustomTag) {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill")
.font(.subheadline)
Text("Add")
.font(.subheadline)
.fontWeight(.medium)
}
}
.foregroundColor(.accentColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(Color.accentColor.opacity(0.1))
.cornerRadius(10)
}
}
@ViewBuilder
private var availableLabels: some View {
if !tagEntities.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(searchText.wrappedValue.isEmpty ? (availableTagsTitle ?? "Available tags") : "Search results")
.font(.subheadline)
.fontWeight(.medium)
if !searchText.wrappedValue.isEmpty {
Text("(\(filteredTagsCount) found)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
if availableUnselectedTagsCount == 0 {
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 {
labelsScrollView
}
}
.padding(.top, 8)
}
}
@ViewBuilder
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(
rows: [
GridItem(.fixed(32), spacing: 8),
GridItem(.fixed(32), spacing: 8),
GridItem(.fixed(32), spacing: 8)
],
alignment: .top,
spacing: 8
) {
ForEach(tagEntities) { entity in
if let name = entity.name, shouldShowTag(name) {
UnifiedLabelChip(
label: name,
isSelected: false,
isRemovable: false,
onTap: {
onToggleLabel(name)
}
)
.fixedSize(horizontal: true, vertical: false)
}
}
}
.frame(height: 120) // 3 rows * 32px + 2 * 8px spacing
.padding(.horizontal)
}
}
// MARK: - Computed Properties & Helper Functions
private var allTagNames: [String] {
tagEntities.compactMap { $0.name }
}
private var filteredTagsCount: Int {
if searchText.wrappedValue.isEmpty {
return tagEntities.count
} else {
return tagEntities.filter { entity in
guard let name = entity.name else { return false }
return name.localizedCaseInsensitiveContains(searchText.wrappedValue)
}.count
}
}
private var availableUnselectedTagsCount: Int {
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 {
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
let isNotSelected = !selectedLabelsSet.contains(name)
return matchesSearch && isNotSelected
}
@ViewBuilder
private var selectedLabels: some View {
if !selectedLabelsSet.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Selected tags")
.font(.subheadline)
.fontWeight(.medium)
FlowLayout(spacing: 8) {
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: true,
isRemovable: true,
onTap: {
// No action for selected labels
},
onRemove: {
onRemoveLabel(label)
}
)
}
}
}
.padding(.top, 8)
}
}
}

View File

@ -1,3 +1,7 @@
// TODO: deprecated - This file is no longer used and can be removed
// Replaced by CoreDataTagManagementView.swift which uses Core Data directly
// instead of fetching labels via API
import SwiftUI import SwiftUI
struct FlowLayout: Layout { struct FlowLayout: Layout {
@ -75,7 +79,7 @@ struct FocusModifier: ViewModifier {
} }
} }
struct TagManagementView: View { struct LegacyTagManagementView: View {
// MARK: - Properties // MARK: - Properties

View File

@ -0,0 +1,14 @@
//
// FontSizeExtension.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
import SwiftUI
extension FontSize {
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -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 makeSyncTagsUseCase() -> PSyncTagsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
@ -102,7 +103,13 @@ class DefaultUseCaseFactory: UseCaseFactory {
let labelsRepository = LabelsRepository(api: api) let labelsRepository = LabelsRepository(api: api)
return GetLabelsUseCase(labelsRepository: labelsRepository) return GetLabelsUseCase(labelsRepository: labelsRepository)
} }
func makeSyncTagsUseCase() -> PSyncTagsUseCase {
let api = API(tokenProvider: KeychainTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return SyncTagsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase { func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase() return AddTextToSpeechQueueUseCase()
} }

View File

@ -76,7 +76,11 @@ class MockUseCaseFactory: UseCaseFactory {
func makeGetLabelsUseCase() -> any PGetLabelsUseCase { func makeGetLabelsUseCase() -> any PGetLabelsUseCase {
MockGetLabelsUseCase() MockGetLabelsUseCase()
} }
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
MockSyncTagsUseCase()
}
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase { func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
MockAddTextToSpeechQueueUseCase() MockAddTextToSpeechQueueUseCase()
} }
@ -125,6 +129,12 @@ class MockGetLabelsUseCase: PGetLabelsUseCase {
} }
} }
class MockSyncTagsUseCase: PSyncTagsUseCase {
func execute() async throws {
// Mock implementation - does nothing
}
}
class MockSearchBookmarksUseCase: PSearchBookmarksUseCase { class MockSearchBookmarksUseCase: PSearchBookmarksUseCase {
func execute(search: String) async throws -> BookmarksPage { func execute(search: String) async throws -> BookmarksPage {
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil) BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)

View File

@ -18,19 +18,23 @@ import Combine
class AppSettings: ObservableObject { class AppSettings: ObservableObject {
@Published var settings: Settings? @Published var settings: Settings?
var enableTTS: Bool { var enableTTS: Bool {
settings?.enableTTS ?? false settings?.enableTTS ?? false
} }
var theme: Theme { var theme: Theme {
settings?.theme ?? .system settings?.theme ?? .system
} }
var urlOpener: UrlOpener { var urlOpener: UrlOpener {
settings?.urlOpener ?? .inAppBrowser settings?.urlOpener ?? .inAppBrowser
} }
var tagSortOrder: TagSortOrder {
settings?.tagSortOrder ?? .byCount
}
init(settings: Settings? = nil) { init(settings: Settings? = nil) {
self.settings = settings self.settings = settings
} }

View File

@ -3,9 +3,12 @@ import SwiftUI
struct AppearanceSettingsView: View { struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine @State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system @State private var selectedTheme: Theme = .system
@State private var selectedTagSortOrder: TagSortOrder = .byCount
@State private var fontViewModel: FontSettingsViewModel @State private var fontViewModel: FontSettingsViewModel
@State private var generalViewModel: SettingsGeneralViewModel @State private var generalViewModel: SettingsGeneralViewModel
@EnvironmentObject private var appSettings: AppSettings
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
private let settingsRepository: PSettingsRepository private let settingsRepository: PSettingsRepository
@ -104,10 +107,20 @@ struct AppearanceSettingsView: View {
await generalViewModel.saveGeneralSettings() await generalViewModel.saveGeneralSettings()
} }
} }
// Tag Sort Order
Picker("Tag sort order", selection: $selectedTagSortOrder) {
ForEach(TagSortOrder.allCases, id: \.self) { sortOrder in
Text(sortOrder.displayName).tag(sortOrder)
}
}
.onChange(of: selectedTagSortOrder) {
saveTagSortOrderSettings()
}
} header: { } header: {
Text("Appearance") Text("Appearance")
} footer: { } footer: {
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.") Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.\n\nTag sort order determines how tags are displayed when adding or editing bookmarks.")
} }
} }
.task { .task {
@ -119,10 +132,11 @@ struct AppearanceSettingsView: View {
private func loadSettings() { private func loadSettings() {
Task { Task {
// Load both theme and card layout from repository // Load theme, card layout, and tag sort order from repository
if let settings = try? await settingsRepository.loadSettings() { if let settings = try? await settingsRepository.loadSettings() {
await MainActor.run { await MainActor.run {
selectedTheme = settings.theme ?? .system selectedTheme = settings.theme ?? .system
selectedTagSortOrder = settings.tagSortOrder ?? .byCount
} }
} }
selectedCardLayout = await loadCardLayoutUseCase.execute() selectedCardLayout = await loadCardLayoutUseCase.execute()
@ -152,6 +166,20 @@ struct AppearanceSettingsView: View {
} }
} }
} }
private func saveTagSortOrderSettings() {
Task {
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
settings.tagSortOrder = selectedTagSortOrder
try? await settingsRepository.saveSettings(settings)
// Update AppSettings to trigger UI updates
await MainActor.run {
appSettings.settings?.tagSortOrder = selectedTagSortOrder
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
}
}
} }
#Preview { #Preview {

View File

@ -99,48 +99,6 @@ class FontSettingsViewModel {
} }
} }
// MARK: - Font Enums (moved from SettingsViewModel)
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -14,4 +14,5 @@ extension Notification.Name {
// MARK: - User Preferences // MARK: - User Preferences
static let cardLayoutChanged = Notification.Name("cardLayoutChanged") static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
static let tagSortOrderChanged = Notification.Name("tagSortOrderChanged")
} }

View File

@ -25,6 +25,7 @@ struct readeckApp: App {
} }
} }
.environmentObject(appSettings) .environmentObject(appSettings)
.environment(\.managedObjectContext, CoreDataManager.shared.context)
.preferredColorScheme(appSettings.theme.colorScheme) .preferredColorScheme(appSettings.theme.colorScheme)
.onAppear { .onAppear {
#if DEBUG #if DEBUG

View File

@ -55,11 +55,13 @@
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fontFamily" optional="YES" attributeType="String"/> <attribute name="fontFamily" optional="YES" attributeType="String"/>
<attribute name="fontSize" optional="YES" attributeType="String"/> <attribute name="fontSize" optional="YES" attributeType="String"/>
<attribute name="tagSortOrder" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="String"/> <attribute name="theme" optional="YES" attributeType="String"/>
<attribute name="token" optional="YES" attributeType="String"/> <attribute name="token" optional="YES" attributeType="String"/>
<attribute name="urlOpener" optional="YES" attributeType="String"/> <attribute name="urlOpener" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class"> <entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
<attribute name="count" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/> <attribute name="name" optional="YES" attributeType="String"/>
</entity> </entity>
</model> </model>