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:
parent
460b05ef34
commit
f3719fa9d4
@ -84,6 +84,7 @@ class OfflineBookmarkManager: @unchecked Sendable {
|
||||
if !existingNames.contains(tag) {
|
||||
let entity = TagEntity(context: backgroundContext)
|
||||
entity.name = tag
|
||||
entity.count = 0
|
||||
insertCount += 1
|
||||
}
|
||||
}
|
||||
@ -99,4 +100,51 @@ class OfflineBookmarkManager: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ShareBookmarkView: View {
|
||||
@ObservedObject var viewModel: ShareBookmarkViewModel
|
||||
@State private var keyboardHeight: CGFloat = 0
|
||||
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
||||
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
private func dismissKeyboard() {
|
||||
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
|
||||
}
|
||||
@ -39,7 +42,6 @@ struct ShareBookmarkView: View {
|
||||
saveButtonSection
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onAppear { viewModel.onAppear() }
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
@ -134,32 +136,30 @@ struct ShareBookmarkView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var tagManagementSection: some View {
|
||||
if !viewModel.labels.isEmpty || !viewModel.isServerReachable {
|
||||
TagManagementView(
|
||||
allLabels: convertToBookmarkLabels(viewModel.labels),
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: false,
|
||||
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
|
||||
searchFieldFocus: $focusedField,
|
||||
onAddCustomTag: {
|
||||
addCustomTag()
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
if viewModel.selectedLabels.contains(label) {
|
||||
viewModel.selectedLabels.remove(label)
|
||||
} else {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
}
|
||||
viewModel.searchText = ""
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
CoreDataTagManagementView(
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
searchFieldFocus: $focusedField,
|
||||
fetchLimit: 150,
|
||||
sortOrder: viewModel.tagSortOrder,
|
||||
availableTagsTitle: "Most used tags",
|
||||
onAddCustomTag: {
|
||||
addCustomTag()
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
if viewModel.selectedLabels.contains(label) {
|
||||
viewModel.selectedLabels.remove(label)
|
||||
} else {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
}
|
||||
)
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
viewModel.searchText = ""
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
viewModel.selectedLabels.remove(label)
|
||||
}
|
||||
)
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -199,17 +199,13 @@ struct ShareBookmarkView: View {
|
||||
|
||||
// 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() {
|
||||
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 uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
|
||||
|
||||
|
||||
@ -6,54 +6,23 @@ import CoreData
|
||||
class ShareBookmarkViewModel: ObservableObject {
|
||||
@Published var url: String?
|
||||
@Published var title: String = ""
|
||||
@Published var labels: [BookmarkLabelDto] = []
|
||||
@Published var selectedLabels: Set<String> = []
|
||||
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
|
||||
@Published var isSaving: Bool = false
|
||||
@Published var searchText: String = ""
|
||||
@Published var isServerReachable: Bool = true
|
||||
let tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount
|
||||
let extensionContext: NSExtensionContext?
|
||||
|
||||
private let logger = Logger.viewModel
|
||||
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?) {
|
||||
self.extensionContext = extensionContext
|
||||
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
|
||||
extractSharedContent()
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
logger.debug("ShareBookmarkViewModel appeared")
|
||||
loadLabels()
|
||||
}
|
||||
|
||||
private func extractSharedContent() {
|
||||
logger.debug("Starting to extract shared content")
|
||||
guard let extensionContext = extensionContext else {
|
||||
@ -120,51 +89,6 @@ 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() {
|
||||
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
|
||||
guard let url = url, !url.isEmpty else {
|
||||
@ -205,19 +129,23 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
)
|
||||
logger.info("Local save result: \(success)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
if success {
|
||||
self.logger.info("Bookmark saved locally successfully")
|
||||
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
self.completeExtensionRequest()
|
||||
}
|
||||
} else {
|
||||
self.logger.error("Failed to save bookmark locally")
|
||||
self.statusMessage = ("Failed to save locally.", true, "❌")
|
||||
}
|
||||
}
|
||||
|
||||
if success {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
await MainActor.run {
|
||||
self.completeExtensionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,13 +12,14 @@ import SwiftUI
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private var hostingController: UIHostingController<ShareBookmarkView>?
|
||||
private var hostingController: UIHostingController<AnyView>?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext)
|
||||
let swiftUIView = ShareBookmarkView(viewModel: viewModel)
|
||||
let hostingController = UIHostingController(rootView: swiftUIView)
|
||||
.environment(\.managedObjectContext, CoreDataManager.shared.context)
|
||||
let hostingController = UIHostingController(rootView: AnyView(swiftUIView))
|
||||
addChild(hostingController)
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(hostingController.view)
|
||||
|
||||
@ -87,12 +87,22 @@
|
||||
Data/Utils/LabelUtils.swift,
|
||||
Domain/Model/Bookmark.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,
|
||||
Splash.storyboard,
|
||||
UI/Components/Constants.swift,
|
||||
UI/Components/CoreDataTagManagementView.swift,
|
||||
UI/Components/CustomTextFieldStyle.swift,
|
||||
UI/Components/TagManagementView.swift,
|
||||
UI/Components/LegacyTagManagementView.swift,
|
||||
UI/Components/UnifiedLabelChip.swift,
|
||||
UI/Extension/FontSizeExtension.swift,
|
||||
UI/Models/AppSettings.swift,
|
||||
UI/Utils/NotificationNames.swift,
|
||||
Utils/Logger.swift,
|
||||
Utils/LogStore.swift,
|
||||
|
||||
@ -14,6 +14,7 @@ extension BookmarkLabelDto {
|
||||
func toEntity(context: NSManagedObjectContext) -> TagEntity {
|
||||
let entity = TagEntity(context: context)
|
||||
entity.name = name
|
||||
entity.count = Int32(count)
|
||||
return entity
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,14 +33,17 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
||||
|
||||
return try await backgroundContext.perform {
|
||||
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)
|
||||
return entities.compactMap { entity -> BookmarkLabel? in
|
||||
guard let name = entity.name, !name.isEmpty else { return nil }
|
||||
return BookmarkLabel(
|
||||
name: name,
|
||||
count: 0,
|
||||
count: Int(entity.count),
|
||||
href: name
|
||||
)
|
||||
}
|
||||
@ -51,24 +54,37 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
||||
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||
|
||||
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()
|
||||
fetchRequest.propertiesToFetch = ["name"]
|
||||
fetchRequest.propertiesToFetch = ["name", "count"]
|
||||
|
||||
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 updateCount = 0
|
||||
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)
|
||||
insertCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Only save if there are new labels
|
||||
if insertCount > 0 {
|
||||
// Only save if there are changes
|
||||
if insertCount > 0 || updateCount > 0 {
|
||||
try backgroundContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,30 +1,6 @@
|
||||
import Foundation
|
||||
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 {
|
||||
func saveSettings(_ settings: Settings) async throws
|
||||
func loadSettings() async throws -> Settings?
|
||||
@ -36,6 +12,8 @@ protocol PSettingsRepository {
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
|
||||
func loadCardLayoutStyle() async throws -> CardLayoutStyle
|
||||
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
|
||||
func loadTagSortOrder() async throws -> TagSortOrder
|
||||
var hasFinishedSetup: Bool { get }
|
||||
}
|
||||
|
||||
@ -101,6 +79,10 @@ class SettingsRepository: PSettingsRepository {
|
||||
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||
}
|
||||
|
||||
if let tagSortOrder = settings.tagSortOrder {
|
||||
existingSettings.tagSortOrder = tagSortOrder.rawValue
|
||||
}
|
||||
|
||||
try context.save()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
@ -139,6 +121,7 @@ class SettingsRepository: PSettingsRepository {
|
||||
enableTTS: settingEntity?.enableTTS,
|
||||
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.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)
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
readeck/Domain/Model/FontFamily.swift
Normal file
23
readeck/Domain/Model/FontFamily.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
readeck/Domain/Model/FontSize.swift
Normal file
33
readeck/Domain/Model/FontSize.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
32
readeck/Domain/Model/Settings.swift
Normal file
32
readeck/Domain/Model/Settings.swift
Normal 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
|
||||
}
|
||||
}
|
||||
20
readeck/Domain/Model/TagSortOrder.swift
Normal file
20
readeck/Domain/Model/TagSortOrder.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,10 @@
|
||||
//
|
||||
// UrlOpener.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 06.11.25.
|
||||
//
|
||||
|
||||
enum UrlOpener: String, CaseIterable {
|
||||
case inAppBrowser = "inAppBrowser"
|
||||
case defaultBrowser = "defaultBrowser"
|
||||
|
||||
21
readeck/Domain/UseCase/SyncTagsUseCase.swift
Normal file
21
readeck/Domain/UseCase/SyncTagsUseCase.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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 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";
|
||||
"Most used tags" = "Meist verwendete Labels";
|
||||
"Sorted by usage count" = "Sortiert nach Verwendungshäufigkeit";
|
||||
"Sorted alphabetically" = "Alphabetisch sortiert";
|
||||
"Cancel" = "Abbrechen";
|
||||
"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).";
|
||||
|
||||
@ -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 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";
|
||||
"Most used tags" = "Most used tags";
|
||||
"Sorted by usage count" = "Sorted by usage count";
|
||||
"Sorted alphabetically" = "Sorted alphabetically";
|
||||
"Cancel" = "Cancel";
|
||||
"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).";
|
||||
|
||||
@ -4,6 +4,8 @@ import UIKit
|
||||
struct AddBookmarkView: View {
|
||||
@State private var viewModel = AddBookmarkViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@EnvironmentObject private var appSettings: AppSettings
|
||||
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
||||
@State private var keyboardHeight: CGFloat = 0
|
||||
|
||||
@ -58,9 +60,9 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.checkClipboard()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadAllLabels()
|
||||
Task {
|
||||
await viewModel.syncTags()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.clearForm()
|
||||
@ -177,23 +179,28 @@ struct AddBookmarkView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var labelsField: some View {
|
||||
TagManagementView(
|
||||
allLabels: viewModel.allLabels,
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: viewModel.isLabelsLoading,
|
||||
filteredLabels: viewModel.filteredLabels,
|
||||
searchFieldFocus: $focusedField,
|
||||
onAddCustomTag: {
|
||||
viewModel.addCustomTag()
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
viewModel.toggleLabel(label)
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
viewModel.removeLabel(label)
|
||||
}
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
CoreDataTagManagementView(
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
searchFieldFocus: $focusedField,
|
||||
fetchLimit: nil,
|
||||
sortOrder: appSettings.tagSortOrder,
|
||||
onAddCustomTag: {
|
||||
viewModel.addCustomTag()
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
viewModel.toggleLabel(label)
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
viewModel.removeLabel(label)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@ -8,6 +8,7 @@ class AddBookmarkViewModel {
|
||||
|
||||
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
|
||||
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
|
||||
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
|
||||
|
||||
// MARK: - Form Data
|
||||
var url: String = ""
|
||||
@ -61,6 +62,13 @@ class AddBookmarkViewModel {
|
||||
|
||||
// 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
|
||||
func loadAllLabels() async {
|
||||
isLabelsLoading = true
|
||||
|
||||
@ -4,6 +4,8 @@ struct BookmarkLabelsView: View {
|
||||
let bookmarkId: String
|
||||
@State private var viewModel: BookmarkLabelsViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@EnvironmentObject private var appSettings: AppSettings
|
||||
|
||||
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) {
|
||||
self.bookmarkId = bookmarkId
|
||||
@ -40,13 +42,15 @@ struct BookmarkLabelsView: View {
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "Unknown error")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadAllLabels()
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.onTapGesture {
|
||||
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
|
||||
private var availableLabelsSection: some View {
|
||||
TagManagementView(
|
||||
allLabels: viewModel.allLabels,
|
||||
selectedLabels: Set(viewModel.currentLabels),
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: viewModel.isInitialLoading,
|
||||
filteredLabels: viewModel.filteredLabels,
|
||||
onAddCustomTag: {
|
||||
Task {
|
||||
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
|
||||
CoreDataTagManagementView(
|
||||
selectedLabels: Set(viewModel.currentLabels),
|
||||
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
|
||||
Task {
|
||||
await viewModel.toggleLabel(for: bookmarkId, label: label)
|
||||
}
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
Task {
|
||||
await viewModel.removeLabel(from: bookmarkId, label: label)
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ class BookmarkLabelsViewModel {
|
||||
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
|
||||
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
|
||||
private let getLabelsUseCase: PGetLabelsUseCase
|
||||
private let syncTagsUseCase: PSyncTagsUseCase
|
||||
|
||||
var isLoading = false
|
||||
var isInitialLoading = false
|
||||
@ -34,7 +35,14 @@ class BookmarkLabelsViewModel {
|
||||
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
|
||||
|
||||
255
readeck/UI/Components/CoreDataTagManagementView.swift
Normal file
255
readeck/UI/Components/CoreDataTagManagementView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
struct FlowLayout: Layout {
|
||||
@ -75,7 +79,7 @@ struct FocusModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
struct TagManagementView: View {
|
||||
struct LegacyTagManagementView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
14
readeck/UI/Extension/FontSizeExtension.swift
Normal file
14
readeck/UI/Extension/FontSizeExtension.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ protocol UseCaseFactory {
|
||||
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
|
||||
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||
func makeSyncTagsUseCase() -> PSyncTagsUseCase
|
||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||
@ -103,6 +104,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
||||
}
|
||||
|
||||
func makeSyncTagsUseCase() -> PSyncTagsUseCase {
|
||||
let api = API(tokenProvider: KeychainTokenProvider())
|
||||
let labelsRepository = LabelsRepository(api: api)
|
||||
return SyncTagsUseCase(labelsRepository: labelsRepository)
|
||||
}
|
||||
|
||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
|
||||
return AddTextToSpeechQueueUseCase()
|
||||
}
|
||||
|
||||
@ -77,6 +77,10 @@ class MockUseCaseFactory: UseCaseFactory {
|
||||
MockGetLabelsUseCase()
|
||||
}
|
||||
|
||||
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
|
||||
MockSyncTagsUseCase()
|
||||
}
|
||||
|
||||
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
|
||||
MockAddTextToSpeechQueueUseCase()
|
||||
}
|
||||
@ -125,6 +129,12 @@ class MockGetLabelsUseCase: PGetLabelsUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
class MockSyncTagsUseCase: PSyncTagsUseCase {
|
||||
func execute() async throws {
|
||||
// Mock implementation - does nothing
|
||||
}
|
||||
}
|
||||
|
||||
class MockSearchBookmarksUseCase: PSearchBookmarksUseCase {
|
||||
func execute(search: String) async throws -> BookmarksPage {
|
||||
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)
|
||||
|
||||
@ -31,6 +31,10 @@ class AppSettings: ObservableObject {
|
||||
settings?.urlOpener ?? .inAppBrowser
|
||||
}
|
||||
|
||||
var tagSortOrder: TagSortOrder {
|
||||
settings?.tagSortOrder ?? .byCount
|
||||
}
|
||||
|
||||
init(settings: Settings? = nil) {
|
||||
self.settings = settings
|
||||
}
|
||||
|
||||
@ -3,9 +3,12 @@ import SwiftUI
|
||||
struct AppearanceSettingsView: View {
|
||||
@State private var selectedCardLayout: CardLayoutStyle = .magazine
|
||||
@State private var selectedTheme: Theme = .system
|
||||
@State private var selectedTagSortOrder: TagSortOrder = .byCount
|
||||
@State private var fontViewModel: FontSettingsViewModel
|
||||
@State private var generalViewModel: SettingsGeneralViewModel
|
||||
|
||||
@EnvironmentObject private var appSettings: AppSettings
|
||||
|
||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
||||
private let settingsRepository: PSettingsRepository
|
||||
@ -104,10 +107,20 @@ struct AppearanceSettingsView: View {
|
||||
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: {
|
||||
Text("Appearance")
|
||||
} 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 {
|
||||
@ -119,10 +132,11 @@ struct AppearanceSettingsView: View {
|
||||
|
||||
private func loadSettings() {
|
||||
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() {
|
||||
await MainActor.run {
|
||||
selectedTheme = settings.theme ?? .system
|
||||
selectedTagSortOrder = settings.tagSortOrder ?? .byCount
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,4 +14,5 @@ extension Notification.Name {
|
||||
|
||||
// MARK: - User Preferences
|
||||
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
|
||||
static let tagSortOrderChanged = Notification.Name("tagSortOrderChanged")
|
||||
}
|
||||
@ -25,6 +25,7 @@ struct readeckApp: App {
|
||||
}
|
||||
}
|
||||
.environmentObject(appSettings)
|
||||
.environment(\.managedObjectContext, CoreDataManager.shared.context)
|
||||
.preferredColorScheme(appSettings.theme.colorScheme)
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
|
||||
@ -55,11 +55,13 @@
|
||||
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="fontFamily" 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="token" optional="YES" attributeType="String"/>
|
||||
<attribute name="urlOpener" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<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"/>
|
||||
</entity>
|
||||
</model>
|
||||
Loading…
x
Reference in New Issue
Block a user