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) {
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
enum UrlOpener: String, CaseIterable {
|
||||||
case inAppBrowser = "inAppBrowser"
|
case inAppBrowser = "inAppBrowser"
|
||||||
case defaultBrowser = "defaultBrowser"
|
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 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).";
|
||||||
|
|||||||
@ -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).";
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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
|
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
|
||||||
|
|
||||||
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 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
// MARK: - User Preferences
|
||||||
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
|
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
|
||||||
|
static let tagSortOrderChanged = Notification.Name("tagSortOrderChanged")
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
Loading…
x
Reference in New Issue
Block a user