diff --git a/URLShare/OfflineBookmarkManager.swift b/URLShare/OfflineBookmarkManager.swift index 28eb785..3aa825d 100644 --- a/URLShare/OfflineBookmarkManager.swift +++ b/URLShare/OfflineBookmarkManager.swift @@ -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 } } @@ -98,5 +99,52 @@ class OfflineBookmarkManager: @unchecked Sendable { 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.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)") + } + } } diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift index 2e78d09..5778a21 100644 --- a/URLShare/ShareBookmarkView.swift +++ b/URLShare/ShareBookmarkView.swift @@ -1,9 +1,12 @@ 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 @@ -198,25 +198,21 @@ 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.fetchRequest() + let availableLabels = (try? viewContext.fetch(fetchRequest))?.compactMap { $0.name } ?? [] + let currentLabels = Array(viewModel.selectedLabels) let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels) - + for label in uniqueLabels { viewModel.selectedLabels.insert(label) } - + viewModel.searchText = "" } } diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index d01a9a8..66941a8 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -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 = [] @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.. $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() + } + } } } } diff --git a/URLShare/ShareViewController.swift b/URLShare/ShareViewController.swift index 64a511d..c398568 100644 --- a/URLShare/ShareViewController.swift +++ b/URLShare/ShareViewController.swift @@ -11,14 +11,15 @@ import UniformTypeIdentifiers import SwiftUI class ShareViewController: UIViewController { - - private var hostingController: UIHostingController? - + + private var hostingController: UIHostingController? + 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) diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index cb8aa43..7e456a5 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -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, diff --git a/readeck/Data/Mappers/TagEntityMapper.swift b/readeck/Data/Mappers/TagEntityMapper.swift index 0e1ea77..12a1c18 100644 --- a/readeck/Data/Mappers/TagEntityMapper.swift +++ b/readeck/Data/Mappers/TagEntityMapper.swift @@ -9,11 +9,12 @@ import Foundation import CoreData extension BookmarkLabelDto { - + @discardableResult func toEntity(context: NSManagedObjectContext) -> TagEntity { let entity = TagEntity(context: context) entity.name = name + entity.count = Int32(count) return entity } } diff --git a/readeck/Data/Repository/LabelsRepository.swift b/readeck/Data/Repository/LabelsRepository.swift index 08e8427..989e433 100644 --- a/readeck/Data/Repository/LabelsRepository.swift +++ b/readeck/Data/Repository/LabelsRepository.swift @@ -33,14 +33,17 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable { return try await backgroundContext.perform { let fetchRequest: NSFetchRequest = 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.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() } } diff --git a/readeck/Data/Repository/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift index 2d4eaf8..fe82342 100644 --- a/readeck/Data/Repository/SettingsRepository.swift +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -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? @@ -33,9 +9,11 @@ protocol PSettingsRepository { func saveUsername(_ username: String) async throws func savePassword(_ password: String) 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 loadCardLayoutStyle() async throws -> CardLayoutStyle + func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws + func loadTagSortOrder() async throws -> TagSortOrder var hasFinishedSetup: Bool { get } } @@ -100,7 +78,11 @@ class SettingsRepository: PSettingsRepository { if let cardLayoutStyle = settings.cardLayoutStyle { 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) @@ -244,16 +227,16 @@ class SettingsRepository: PSettingsRepository { func loadCardLayoutStyle() async throws -> CardLayoutStyle { let context = coreDataManager.context - + return try await withCheckedThrowingContinuation { continuation in context.perform { do { let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() fetchRequest.fetchLimit = 1 - + let settingEntities = try context.fetch(fetchRequest) let settingEntity = settingEntities.first - + let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine continuation.resume(returning: cardLayoutStyle) } 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.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.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) + } + } + } + } } diff --git a/readeck/Domain/Model/FontFamily.swift b/readeck/Domain/Model/FontFamily.swift new file mode 100644 index 0000000..c29219f --- /dev/null +++ b/readeck/Domain/Model/FontFamily.swift @@ -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" + } + } +} \ No newline at end of file diff --git a/readeck/Domain/Model/FontSize.swift b/readeck/Domain/Model/FontSize.swift new file mode 100644 index 0000000..0f50d45 --- /dev/null +++ b/readeck/Domain/Model/FontSize.swift @@ -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 + } + } +} diff --git a/readeck/Domain/Model/Settings.swift b/readeck/Domain/Model/Settings.swift new file mode 100644 index 0000000..386502b --- /dev/null +++ b/readeck/Domain/Model/Settings.swift @@ -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 + } +} diff --git a/readeck/Domain/Model/TagSortOrder.swift b/readeck/Domain/Model/TagSortOrder.swift new file mode 100644 index 0000000..920610c --- /dev/null +++ b/readeck/Domain/Model/TagSortOrder.swift @@ -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" + } + } +} diff --git a/readeck/Domain/Model/UrlOpener.swift b/readeck/Domain/Model/UrlOpener.swift index 59b9b01..23e21e6 100644 --- a/readeck/Domain/Model/UrlOpener.swift +++ b/readeck/Domain/Model/UrlOpener.swift @@ -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" diff --git a/readeck/Domain/UseCase/SyncTagsUseCase.swift b/readeck/Domain/UseCase/SyncTagsUseCase.swift new file mode 100644 index 0000000..8c04710 --- /dev/null +++ b/readeck/Domain/UseCase/SyncTagsUseCase.swift @@ -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() + } +} diff --git a/readeck/Localizations/de.lproj/Localizable.strings b/readeck/Localizations/de.lproj/Localizable.strings index f2253a1..0b938f9 100644 --- a/readeck/Localizations/de.lproj/Localizable.strings +++ b/readeck/Localizations/de.lproj/Localizable.strings @@ -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)."; diff --git a/readeck/Localizations/en.lproj/Localizable.strings b/readeck/Localizations/en.lproj/Localizable.strings index dfe7fca..67078d4 100644 --- a/readeck/Localizations/en.lproj/Localizable.strings +++ b/readeck/Localizations/en.lproj/Localizable.strings @@ -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)."; diff --git a/readeck/UI/AddBookmark/AddBookmarkView.swift b/readeck/UI/AddBookmark/AddBookmarkView.swift index 223243a..65f22f1 100644 --- a/readeck/UI/AddBookmark/AddBookmarkView.swift +++ b/readeck/UI/AddBookmark/AddBookmarkView.swift @@ -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 diff --git a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift index 6ad8135..01f113a 100644 --- a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift +++ b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift @@ -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 = "" @@ -60,12 +61,19 @@ 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 defer { isLabelsLoading = false } - + do { let labels = try await getLabelsUseCase.execute() allLabels = labels.sorted { $0.count > $1.count } diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift index f72dc3c..27c86b0 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift @@ -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) + } } } diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift index da4af3d..99968e7 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift @@ -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 @@ -30,13 +31,20 @@ class BookmarkLabelsViewModel { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) { self.currentLabels = initialLabels - + self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase() self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase() self.getLabelsUseCase = factory.makeGetLabelsUseCase() - + self.syncTagsUseCase = factory.makeSyncTagsUseCase() } + /// Triggers background sync of tags from server to Core Data + /// CoreDataTagManagementView will automatically update via @FetchRequest + @MainActor + func syncTags() async { + try? await syncTagsUseCase.execute() + } + @MainActor func loadAllLabels() async { isInitialLoading = true diff --git a/readeck/UI/Components/CoreDataTagManagementView.swift b/readeck/UI/Components/CoreDataTagManagementView.swift new file mode 100644 index 0000000..b3fd97d --- /dev/null +++ b/readeck/UI/Components/CoreDataTagManagementView.swift @@ -0,0 +1,255 @@ +import SwiftUI +import CoreData + +struct CoreDataTagManagementView: View { + + // MARK: - Properties + + let selectedLabelsSet: Set + let searchText: Binding + let searchFieldFocus: FocusState.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 + + // MARK: - Initialization + + init( + selectedLabels: Set, + searchText: Binding, + searchFieldFocus: FocusState.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.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) + } + } +} diff --git a/readeck/UI/Components/TagManagementView.swift b/readeck/UI/Components/LegacyTagManagementView.swift similarity index 97% rename from readeck/UI/Components/TagManagementView.swift rename to readeck/UI/Components/LegacyTagManagementView.swift index a680faa..f3ded78 100644 --- a/readeck/UI/Components/TagManagementView.swift +++ b/readeck/UI/Components/LegacyTagManagementView.swift @@ -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 diff --git a/readeck/UI/Extension/FontSizeExtension.swift b/readeck/UI/Extension/FontSizeExtension.swift new file mode 100644 index 0000000..4cfc66c --- /dev/null +++ b/readeck/UI/Extension/FontSizeExtension.swift @@ -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) + } +} diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index 34798d7..dd23504 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -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 @@ -102,7 +103,13 @@ class DefaultUseCaseFactory: UseCaseFactory { let labelsRepository = LabelsRepository(api: api) 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() } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index fb83faf..a86aba6 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -76,7 +76,11 @@ class MockUseCaseFactory: UseCaseFactory { func makeGetLabelsUseCase() -> any PGetLabelsUseCase { 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) diff --git a/readeck/UI/Models/AppSettings.swift b/readeck/UI/Models/AppSettings.swift index 4ab7c18..c09d8ff 100644 --- a/readeck/UI/Models/AppSettings.swift +++ b/readeck/UI/Models/AppSettings.swift @@ -18,19 +18,23 @@ import Combine class AppSettings: ObservableObject { @Published var settings: Settings? - + var enableTTS: Bool { settings?.enableTTS ?? false } - + var theme: Theme { settings?.theme ?? .system } - + var urlOpener: UrlOpener { settings?.urlOpener ?? .inAppBrowser } + var tagSortOrder: TagSortOrder { + settings?.tagSortOrder ?? .byCount + } + init(settings: Settings? = nil) { self.settings = settings } diff --git a/readeck/UI/Settings/AppearanceSettingsView.swift b/readeck/UI/Settings/AppearanceSettingsView.swift index c5780c5..a423b69 100644 --- a/readeck/UI/Settings/AppearanceSettingsView.swift +++ b/readeck/UI/Settings/AppearanceSettingsView.swift @@ -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 { diff --git a/readeck/UI/Settings/FontSettingsViewModel.swift b/readeck/UI/Settings/FontSettingsViewModel.swift index 374c1dc..0b313df 100644 --- a/readeck/UI/Settings/FontSettingsViewModel.swift +++ b/readeck/UI/Settings/FontSettingsViewModel.swift @@ -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) - } -} + + diff --git a/readeck/UI/Utils/NotificationNames.swift b/readeck/UI/Utils/NotificationNames.swift index d3f7716..c420aa8 100644 --- a/readeck/UI/Utils/NotificationNames.swift +++ b/readeck/UI/Utils/NotificationNames.swift @@ -14,4 +14,5 @@ extension Notification.Name { // MARK: - User Preferences static let cardLayoutChanged = Notification.Name("cardLayoutChanged") + static let tagSortOrderChanged = Notification.Name("tagSortOrderChanged") } \ No newline at end of file diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 7723755..61292a1 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -25,6 +25,7 @@ struct readeckApp: App { } } .environmentObject(appSettings) + .environment(\.managedObjectContext, CoreDataManager.shared.context) .preferredColorScheme(appSettings.theme.colorScheme) .onAppear { #if DEBUG diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index d371f92..37064bb 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -55,11 +55,13 @@ + + \ No newline at end of file