Merge branch 'develop'
This commit is contained in:
commit
ad606d528c
27
CHANGELOG.md
27
CHANGELOG.md
@ -1,27 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
## Planned for Version 1.0.0
|
|
||||||
|
|
||||||
**Initial release:**
|
|
||||||
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
|
|
||||||
- Share Extension for adding URLs from Safari and other apps
|
|
||||||
- Swipe actions for quick bookmark management
|
|
||||||
- Native iOS design with Dark Mode support
|
|
||||||
- Full iPad Support with Multi-Column Split View
|
|
||||||
- Font Customization
|
|
||||||
- Article View with Reading Time and Word Count
|
|
||||||
- Search functionality
|
|
||||||
- Support for tags
|
|
||||||
- Support for reading progress
|
|
||||||
- Save bookmarks when server is unavailable and sync when reconnected
|
|
||||||
|
|
||||||
## Planned for Version 1.1.0
|
|
||||||
|
|
||||||
- [ ] Add support for bookmark filtering and sorting options
|
|
||||||
- [ ] Add support for collection management
|
|
||||||
- [ ] Add support for custom themes
|
|
||||||
- [ ] Text highlighting of selected text in a article
|
|
||||||
- [ ] Multiple selection of bookmarks for bulk actions
|
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ For early access to new features and beta versions (use with caution). To partic
|
|||||||
|
|
||||||
What to test:
|
What to test:
|
||||||
- See the feature list below for an overview of what you can try out.
|
- See the feature list below for an overview of what you can try out.
|
||||||
- For details and recent changes, please refer to the release notes in TestFlight or the [Changelog](./CHANGELOG.md).
|
- For details and recent changes, please refer to the release notes in TestFlight or the [Release Notes](./readeck/UI/Resources/RELEASE_NOTES.md).
|
||||||
|
|
||||||
Please report any bugs, crashes, or suggestions directly through TestFlight, or email me at ilhallak@gmail.com. Thank you for helping make Readeck better!
|
Please report any bugs, crashes, or suggestions directly through TestFlight, or email me at ilhallak@gmail.com. Thank you for helping make Readeck better!
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
|
|||||||
|
|
||||||
## Versions
|
## Versions
|
||||||
|
|
||||||
[see Changelog](./CHANGELOG.md)
|
[see Release Notes](./readeck/UI/Resources/RELEASE_NOTES.md)
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@ -49,19 +49,102 @@ class OfflineBookmarkManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTags() -> [String] {
|
func getTags() async -> [String] {
|
||||||
|
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try context.safePerform { [weak self] in
|
return try await backgroundContext.perform {
|
||||||
guard let self = self else { return [] }
|
|
||||||
|
|
||||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
let tagEntities = try self.context.fetch(fetchRequest)
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
|
||||||
return tagEntities.compactMap { $0.name }.sorted()
|
|
||||||
|
let tagEntities = try backgroundContext.fetch(fetchRequest)
|
||||||
|
return tagEntities.compactMap { $0.name }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch tags: \(error)")
|
print("Failed to fetch tags: \(error)")
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveTags(_ tags: [String]) 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)
|
||||||
|
let existingNames = Set(existingEntities.compactMap { $0.name })
|
||||||
|
|
||||||
|
// Only insert new tags
|
||||||
|
var insertCount = 0
|
||||||
|
for tag in tags {
|
||||||
|
if !existingNames.contains(tag) {
|
||||||
|
let entity = TagEntity(context: backgroundContext)
|
||||||
|
entity.name = tag
|
||||||
|
entity.count = 0
|
||||||
|
insertCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only save if there are new tags
|
||||||
|
if insertCount > 0 {
|
||||||
|
try backgroundContext.save()
|
||||||
|
print("Saved \(insertCount) new tags to Core Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
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,62 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
|
|
||||||
class ServerConnectivity: ObservableObject {
|
|
||||||
@Published var isServerReachable = false
|
|
||||||
|
|
||||||
static let shared = ServerConnectivity()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// Check if the Readeck server endpoint is reachable
|
|
||||||
static func isServerReachable() async -> Bool {
|
|
||||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
|
||||||
!endpoint.isEmpty,
|
|
||||||
let url = URL(string: endpoint + "/api/health") else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "GET"
|
|
||||||
request.timeoutInterval = 5.0 // 5 second timeout
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
return httpResponse.statusCode == 200
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("Server connectivity check failed: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alternative check using ping-style endpoint
|
|
||||||
static func isServerReachableSync() -> Bool {
|
|
||||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
|
||||||
!endpoint.isEmpty,
|
|
||||||
let url = URL(string: endpoint) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
|
||||||
var isReachable = false
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "HEAD" // Just check if server responds
|
|
||||||
request.timeoutInterval = 3.0
|
|
||||||
|
|
||||||
let task = URLSession.shared.dataTask(with: request) { _, response, error in
|
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error
|
|
||||||
}
|
|
||||||
semaphore.signal()
|
|
||||||
}
|
|
||||||
|
|
||||||
task.resume()
|
|
||||||
_ = semaphore.wait(timeout: .now() + 3.0)
|
|
||||||
|
|
||||||
return isReachable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,31 @@ 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),
|
availableLabelsTitle: "Most used labels",
|
||||||
searchFieldFocus: $focusedField,
|
context: viewContext,
|
||||||
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 +199,8 @@ 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)
|
viewModel.addCustomTag(context: viewContext)
|
||||||
let availableLabels = viewModel.labels.map { $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 = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,61 +6,24 @@ 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
|
||||||
var availableLabels: [BookmarkLabelDto] {
|
private let tagRepository = TagRepository()
|
||||||
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")
|
|
||||||
checkServerReachability()
|
|
||||||
loadLabels()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkServerReachability() {
|
|
||||||
let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger)
|
|
||||||
isServerReachable = ServerConnectivity.isServerReachableSync()
|
|
||||||
logger.info("Server reachability checked: \(isServerReachable)")
|
|
||||||
measurement.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
@ -126,39 +89,7 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadLabels() {
|
|
||||||
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
|
|
||||||
logger.debug("Starting to load labels")
|
|
||||||
Task {
|
|
||||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
|
||||||
logger.debug("Server reachable for labels: \(serverReachable)")
|
|
||||||
|
|
||||||
if serverReachable {
|
|
||||||
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
|
||||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
|
||||||
} ?? []
|
|
||||||
let sorted = loaded.sorted { $0.count > $1.count }
|
|
||||||
await MainActor.run {
|
|
||||||
self.labels = Array(sorted)
|
|
||||||
self.logger.info("Loaded \(loaded.count) labels from API")
|
|
||||||
measurement.end()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let localTags = OfflineBookmarkManager.shared.getTags()
|
|
||||||
let localLabels = localTags.enumerated().map { index, tagName in
|
|
||||||
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
|
|
||||||
}
|
|
||||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
||||||
await MainActor.run {
|
|
||||||
self.labels = localLabels
|
|
||||||
self.logger.info("Loaded \(localLabels.count) labels from local database")
|
|
||||||
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 {
|
||||||
@ -168,14 +99,14 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
isSaving = true
|
isSaving = true
|
||||||
logger.debug("Set saving state to true")
|
logger.debug("Set saving state to true")
|
||||||
|
|
||||||
// Check server connectivity
|
// Check server connectivity
|
||||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
Task {
|
||||||
logger.debug("Server connectivity for save: \(serverReachable)")
|
let serverReachable = await serverCheck.checkServerReachability()
|
||||||
if serverReachable {
|
logger.debug("Server connectivity for save: \(serverReachable)")
|
||||||
// Online - try to save via API
|
if serverReachable {
|
||||||
logger.info("Attempting to save bookmark via API")
|
// Online - try to save via API
|
||||||
Task {
|
logger.info("Attempting to save bookmark via API")
|
||||||
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
||||||
self?.logger.info("API save completed - Success: \(!error), Message: \(message)")
|
self?.logger.info("API save completed - Success: \(!error), Message: \(message)")
|
||||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||||
@ -189,40 +120,67 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
self?.logger.error("Failed to save bookmark via API: \(message)")
|
self?.logger.error("Failed to save bookmark via API: \(message)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
// Server not reachable - save locally
|
||||||
// Server not reachable - save locally
|
logger.info("Server not reachable, attempting local save")
|
||||||
logger.info("Server not reachable, attempting local save")
|
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
||||||
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
url: url,
|
||||||
url: url,
|
title: title,
|
||||||
title: title,
|
tags: Array(selectedLabels)
|
||||||
tags: Array(selectedLabels)
|
)
|
||||||
)
|
logger.info("Local save result: \(success)")
|
||||||
logger.info("Local save result: \(success)")
|
|
||||||
|
await MainActor.run {
|
||||||
DispatchQueue.main.async {
|
self.isSaving = false
|
||||||
self.isSaving = false
|
if success {
|
||||||
|
self.logger.info("Bookmark saved locally successfully")
|
||||||
|
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
||||||
|
} else {
|
||||||
|
self.logger.error("Failed to save bookmark locally")
|
||||||
|
self.statusMessage = ("Failed to save locally.", true, "❌")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if success {
|
if success {
|
||||||
self.logger.info("Bookmark saved locally successfully")
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
await MainActor.run {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
||||||
self.completeExtensionRequest()
|
self.completeExtensionRequest()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
self.logger.error("Failed to save bookmark locally")
|
|
||||||
self.statusMessage = ("Failed to save locally.", true, "❌")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addCustomTag(context: NSManagedObjectContext) {
|
||||||
|
let splitLabels = LabelUtils.splitLabelsFromInput(searchText)
|
||||||
|
|
||||||
|
// Fetch available labels from Core Data
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
let availableLabels = (try? context.fetch(fetchRequest))?.compactMap { $0.name } ?? []
|
||||||
|
|
||||||
|
let currentLabels = Array(selectedLabels)
|
||||||
|
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
|
||||||
|
|
||||||
|
for label in uniqueLabels {
|
||||||
|
selectedLabels.insert(label)
|
||||||
|
// Save new label to Core Data so it's available next time
|
||||||
|
tagRepository.saveNewLabel(name: label, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force refresh of @FetchRequest in CoreDataTagManagementView
|
||||||
|
// This ensures newly created labels appear immediately in the search results
|
||||||
|
context.refreshAllObjects()
|
||||||
|
|
||||||
|
searchText = ""
|
||||||
|
}
|
||||||
|
|
||||||
private func completeExtensionRequest() {
|
private func completeExtensionRequest() {
|
||||||
logger.debug("Completing extension request")
|
logger.debug("Completing extension request")
|
||||||
guard let context = extensionContext else {
|
guard let context = extensionContext else {
|
||||||
logger.warning("Extension context not available for completion")
|
logger.warning("Extension context not available for completion")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
context.completeRequest(returningItems: []) { [weak self] error in
|
context.completeRequest(returningItems: []) { [weak self] error in
|
||||||
if error {
|
if error {
|
||||||
self?.logger.error("Extension completion failed: \(error)")
|
self?.logger.error("Extension completion failed: \(error)")
|
||||||
|
|||||||
41
URLShare/ShareExtensionServerCheck.swift
Normal file
41
URLShare/ShareExtensionServerCheck.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Simple server check manager for Share Extension with caching
|
||||||
|
class ShareExtensionServerCheck {
|
||||||
|
static let shared = ShareExtensionServerCheck()
|
||||||
|
|
||||||
|
// Cache properties
|
||||||
|
private var cachedResult: Bool?
|
||||||
|
private var lastCheckTime: Date?
|
||||||
|
private let cacheTTL: TimeInterval = 30.0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func checkServerReachability() async -> Bool {
|
||||||
|
// Check cache first
|
||||||
|
if let cached = getCachedResult() {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use SimpleAPI for actual check
|
||||||
|
let result = await SimpleAPI.checkServerReachability()
|
||||||
|
updateCache(result: result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Management
|
||||||
|
|
||||||
|
private func getCachedResult() -> Bool? {
|
||||||
|
guard let lastCheck = lastCheckTime,
|
||||||
|
Date().timeIntervalSince(lastCheck) < cacheTTL,
|
||||||
|
let cached = cachedResult else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCache(result: Bool) {
|
||||||
|
cachedResult = result
|
||||||
|
lastCheckTime = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -2,7 +2,40 @@ import Foundation
|
|||||||
|
|
||||||
class SimpleAPI {
|
class SimpleAPI {
|
||||||
private static let logger = Logger.network
|
private static let logger = Logger.network
|
||||||
|
|
||||||
|
// MARK: - Server Info
|
||||||
|
|
||||||
|
static func checkServerReachability() async -> Bool {
|
||||||
|
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
||||||
|
!endpoint.isEmpty,
|
||||||
|
let url = URL(string: "\(endpoint)/api/info") else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
request.timeoutInterval = 5.0
|
||||||
|
|
||||||
|
if let token = KeychainHelper.shared.loadToken() {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
if let httpResponse = response as? HTTPURLResponse,
|
||||||
|
200...299 ~= httpResponse.statusCode {
|
||||||
|
logger.info("Server is reachable")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Server reachability check failed: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - API Methods
|
// MARK: - API Methods
|
||||||
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
|
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
|
||||||
logger.info("Adding bookmark: \(url)")
|
logger.info("Adding bookmark: \(url)")
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
public struct ServerInfoDto: Codable {
|
||||||
|
public let version: String
|
||||||
|
public let buildDate: String?
|
||||||
|
public let userAgent: String?
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey {
|
||||||
|
case version
|
||||||
|
case buildDate = "build_date"
|
||||||
|
case userAgent = "user_agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct CreateBookmarkRequestDto: Codable {
|
public struct CreateBookmarkRequestDto: Codable {
|
||||||
public let labels: [String]?
|
public let labels: [String]?
|
||||||
public let title: String?
|
public let title: String?
|
||||||
@ -33,4 +45,3 @@ public struct BookmarkLabelDto: Codable, Identifiable {
|
|||||||
self.href = href
|
self.href = href
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
URLShare/TagRepository.swift
Normal file
63
URLShare/TagRepository.swift
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
/// Simple repository for managing tags in Share Extension
|
||||||
|
class TagRepository {
|
||||||
|
|
||||||
|
private let logger = Logger.data
|
||||||
|
|
||||||
|
/// Saves a new label to Core Data if it doesn't already exist
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: The label name to save
|
||||||
|
/// - context: The managed object context to use
|
||||||
|
func saveNewLabel(name: String, context: NSManagedObjectContext) {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
|
||||||
|
// Perform save in a synchronous block to ensure it completes before extension closes
|
||||||
|
context.performAndWait {
|
||||||
|
// Check if label already exists
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
let existingTags = try context.fetch(fetchRequest)
|
||||||
|
|
||||||
|
// Only create if it doesn't exist
|
||||||
|
if existingTags.isEmpty {
|
||||||
|
let newTag = TagEntity(context: context)
|
||||||
|
newTag.name = trimmedName
|
||||||
|
newTag.count = 1 // New label is being used immediately
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
logger.info("Successfully saved new label '\(trimmedName)' to Core Data")
|
||||||
|
|
||||||
|
// Force immediate persistence to disk for share extension
|
||||||
|
// Based on: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
|
||||||
|
// 1. Process pending changes
|
||||||
|
context.processPendingChanges()
|
||||||
|
|
||||||
|
// 2. Ensure persistent store coordinator writes to disk
|
||||||
|
// This is critical for extensions as they may be terminated quickly
|
||||||
|
if context.persistentStoreCoordinator != nil {
|
||||||
|
// Refresh all objects to ensure changes are pushed to store
|
||||||
|
context.refreshAllObjects()
|
||||||
|
|
||||||
|
// Reset staleness interval temporarily to force immediate persistence
|
||||||
|
let originalStalenessInterval = context.stalenessInterval
|
||||||
|
context.stalenessInterval = 0
|
||||||
|
context.refreshAllObjects()
|
||||||
|
context.stalenessInterval = originalStalenessInterval
|
||||||
|
|
||||||
|
logger.debug("Forced context refresh to ensure persistence")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug("Label '\(trimmedName)' already exists, skipping creation")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to save new label '\(trimmedName)': \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
||||||
|
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; };
|
||||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@ -86,14 +87,25 @@
|
|||||||
Data/Utils/LabelUtils.swift,
|
Data/Utils/LabelUtils.swift,
|
||||||
Domain/Model/Bookmark.swift,
|
Domain/Model/Bookmark.swift,
|
||||||
Domain/Model/BookmarkLabel.swift,
|
Domain/Model/BookmarkLabel.swift,
|
||||||
Logger.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/LogStore.swift,
|
||||||
);
|
);
|
||||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||||
};
|
};
|
||||||
@ -151,6 +163,7 @@
|
|||||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||||
|
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -242,6 +255,7 @@
|
|||||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||||
5D9D95482E623668009AF769 /* Kingfisher */,
|
5D9D95482E623668009AF769 /* Kingfisher */,
|
||||||
|
5D48E6012EB402F50043F90F /* MarkdownUI */,
|
||||||
);
|
);
|
||||||
productName = readeck;
|
productName = readeck;
|
||||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||||
@ -333,6 +347,7 @@
|
|||||||
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
||||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
||||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
|
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
||||||
@ -437,7 +452,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 30;
|
CURRENT_PROJECT_VERSION = 36;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -450,7 +465,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -470,7 +485,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 30;
|
CURRENT_PROJECT_VERSION = 36;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -483,7 +498,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@ -625,7 +640,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 30;
|
CURRENT_PROJECT_VERSION = 36;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -648,7 +663,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -669,7 +684,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 30;
|
CURRENT_PROJECT_VERSION = 36;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -692,7 +707,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -854,6 +869,14 @@
|
|||||||
minimumVersion = 1.21.0;
|
minimumVersion = 1.21.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.4.1;
|
||||||
|
};
|
||||||
|
};
|
||||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
||||||
@ -878,6 +901,11 @@
|
|||||||
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
||||||
productName = netfox;
|
productName = netfox;
|
||||||
};
|
};
|
||||||
|
5D48E6012EB402F50043F90F /* MarkdownUI */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||||
|
productName = MarkdownUI;
|
||||||
|
};
|
||||||
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457",
|
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "kingfisher",
|
"identity" : "kingfisher",
|
||||||
@ -19,6 +19,15 @@
|
|||||||
"version" : "1.21.0"
|
"version" : "1.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "networkimage",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||||
|
"version" : "6.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "r.swift",
|
"identity" : "r.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -37,6 +46,24 @@
|
|||||||
"version" : "1.6.1"
|
"version" : "1.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-cmark",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||||
|
"version" : "0.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-markdown-ui",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||||
|
"version" : "2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "xcodeedit",
|
"identity" : "xcodeedit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -18,6 +18,9 @@ protocol PAPI {
|
|||||||
func deleteBookmark(id: String) async throws
|
func deleteBookmark(id: String) async throws
|
||||||
func searchBookmarks(search: String) async throws -> BookmarksPageDto
|
func searchBookmarks(search: String) async throws -> BookmarksPageDto
|
||||||
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
|
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
|
||||||
|
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
|
||||||
|
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
class API: PAPI {
|
class API: PAPI {
|
||||||
@ -435,15 +438,93 @@ class API: PAPI {
|
|||||||
logger.debug("Fetching bookmark labels")
|
logger.debug("Fetching bookmark labels")
|
||||||
let endpoint = "/api/bookmarks/labels"
|
let endpoint = "/api/bookmarks/labels"
|
||||||
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||||
|
|
||||||
let result = try await makeJSONRequest(
|
let result = try await makeJSONRequest(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
responseType: [BookmarkLabelDto].self
|
responseType: [BookmarkLabelDto].self
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Successfully fetched \(result.count) bookmark labels")
|
logger.info("Successfully fetched \(result.count) bookmark labels")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] {
|
||||||
|
logger.debug("Fetching annotations for bookmark: \(bookmarkId)")
|
||||||
|
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
|
||||||
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||||
|
|
||||||
|
let result = try await makeJSONRequest(
|
||||||
|
endpoint: endpoint,
|
||||||
|
responseType: [AnnotationDto].self
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto {
|
||||||
|
logger.debug("Creating annotation for bookmark: \(bookmarkId)")
|
||||||
|
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
|
||||||
|
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
|
||||||
|
|
||||||
|
let bodyDict: [String: Any] = [
|
||||||
|
"color": color,
|
||||||
|
"start_offset": startOffset,
|
||||||
|
"end_offset": endOffset,
|
||||||
|
"start_selector": startSelector,
|
||||||
|
"end_selector": endSelector
|
||||||
|
]
|
||||||
|
|
||||||
|
let bodyData = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
|
||||||
|
|
||||||
|
let result = try await makeJSONRequest(
|
||||||
|
endpoint: endpoint,
|
||||||
|
method: .POST,
|
||||||
|
body: bodyData,
|
||||||
|
responseType: AnnotationDto.self
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
||||||
|
logger.info("Deleting annotation: \(annotationId) from bookmark: \(bookmarkId)")
|
||||||
|
|
||||||
|
let baseURL = await self.baseURL
|
||||||
|
let fullEndpoint = "/api/bookmarks/\(bookmarkId)/annotations/\(annotationId)"
|
||||||
|
|
||||||
|
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
|
||||||
|
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "DELETE"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if let token = await tokenProvider.getToken() {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString)
|
||||||
|
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for DELETE \(url.absoluteString)")
|
||||||
|
throw APIError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||||
|
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
|
logger.info("Successfully deleted annotation: \(annotationId)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HTTPMethod: String {
|
enum HTTPMethod: String {
|
||||||
|
|||||||
21
readeck/Data/API/DTOs/AnnotationDto.swift
Normal file
21
readeck/Data/API/DTOs/AnnotationDto.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AnnotationDto: Codable {
|
||||||
|
let id: String
|
||||||
|
let text: String
|
||||||
|
let created: String
|
||||||
|
let startOffset: Int
|
||||||
|
let endOffset: Int
|
||||||
|
let startSelector: String
|
||||||
|
let endSelector: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case text
|
||||||
|
case created
|
||||||
|
case startOffset = "start_offset"
|
||||||
|
case endOffset = "end_offset"
|
||||||
|
case startSelector = "start_selector"
|
||||||
|
case endSelector = "end_selector"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
readeck/Data/API/DTOs/ServerInfoDto.swift
Normal file
13
readeck/Data/API/DTOs/ServerInfoDto.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ServerInfoDto: Codable {
|
||||||
|
let version: String
|
||||||
|
let buildDate: String?
|
||||||
|
let userAgent: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case version
|
||||||
|
case buildDate = "build_date"
|
||||||
|
case userAgent = "user_agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
readeck/Data/API/InfoApiClient.swift
Normal file
55
readeck/Data/API/InfoApiClient.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// InfoApiClient.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude Code
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PInfoApiClient {
|
||||||
|
func getServerInfo() async throws -> ServerInfoDto
|
||||||
|
}
|
||||||
|
|
||||||
|
class InfoApiClient: PInfoApiClient {
|
||||||
|
private let tokenProvider: TokenProvider
|
||||||
|
private let logger = Logger.network
|
||||||
|
|
||||||
|
init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
|
||||||
|
self.tokenProvider = tokenProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func getServerInfo() async throws -> ServerInfoDto {
|
||||||
|
guard let endpoint = await tokenProvider.getEndpoint(),
|
||||||
|
let url = URL(string: "\(endpoint)/api/info") else {
|
||||||
|
logger.error("Invalid endpoint URL for server info")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
request.timeoutInterval = 5.0
|
||||||
|
|
||||||
|
if let token = await tokenProvider.getToken() {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "GET", url: url.absoluteString)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for server info")
|
||||||
|
throw APIError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
logger.logNetworkError(method: "GET", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "GET", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
|
|
||||||
|
return try JSONDecoder().decode(ServerInfoDto.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,11 @@ class CoreDataManager {
|
|||||||
self?.logger.info("Core Data persistent store loaded successfully")
|
self?.logger.info("Core Data persistent store loaded successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure viewContext for better extension support
|
||||||
|
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||||
|
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||||
|
|
||||||
return container
|
return container
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
readeck/Data/Repository/AnnotationsRepository.swift
Normal file
28
readeck/Data/Repository/AnnotationsRepository.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class AnnotationsRepository: PAnnotationsRepository {
|
||||||
|
private let api: PAPI
|
||||||
|
|
||||||
|
init(api: PAPI) {
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] {
|
||||||
|
let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId)
|
||||||
|
return annotationDtos.map { dto in
|
||||||
|
Annotation(
|
||||||
|
id: dto.id,
|
||||||
|
text: dto.text,
|
||||||
|
created: dto.created,
|
||||||
|
startOffset: dto.startOffset,
|
||||||
|
endOffset: dto.endOffset,
|
||||||
|
startSelector: dto.startSelector,
|
||||||
|
endSelector: dto.endSelector
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
||||||
|
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,34 +11,107 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getLabels() async throws -> [BookmarkLabel] {
|
func getLabels() async throws -> [BookmarkLabel] {
|
||||||
let dtos = try await api.getBookmarkLabels()
|
// First, load from Core Data (instant response)
|
||||||
try? await saveLabels(dtos)
|
let cachedLabels = try await loadLabelsFromCoreData()
|
||||||
return dtos.map { $0.toDomain() }
|
|
||||||
|
// Then sync with API in background (don't wait)
|
||||||
|
Task.detached(priority: .background) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
do {
|
||||||
|
let dtos = try await self.api.getBookmarkLabels()
|
||||||
|
try? await self.saveLabels(dtos)
|
||||||
|
} catch {
|
||||||
|
// Silent fail - we already have cached data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadLabelsFromCoreData() async throws -> [BookmarkLabel] {
|
||||||
|
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||||
|
|
||||||
|
return try await backgroundContext.perform {
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
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: Int(entity.count),
|
||||||
|
href: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
|
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
|
||||||
let backgroundContext = coreDataManager.newBackgroundContext()
|
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||||
|
|
||||||
try await backgroundContext.perform { [weak self] in
|
try await backgroundContext.perform {
|
||||||
guard let self = self else { return }
|
// Batch fetch all existing labels
|
||||||
for dto in dtos {
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
if !self.tagExists(name: dto.name, in: backgroundContext) {
|
fetchRequest.propertiesToFetch = ["name", "count"]
|
||||||
dto.toEntity(context: backgroundContext)
|
|
||||||
|
let existingEntities = try backgroundContext.fetch(fetchRequest)
|
||||||
|
var existingByName: [String: TagEntity] = [:]
|
||||||
|
for entity in existingEntities {
|
||||||
|
if let name = entity.name {
|
||||||
|
existingByName[name] = entity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try backgroundContext.save()
|
|
||||||
|
// Insert or update labels
|
||||||
|
var insertCount = 0
|
||||||
|
var updateCount = 0
|
||||||
|
for dto in dtos {
|
||||||
|
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 changes
|
||||||
|
if insertCount > 0 || updateCount > 0 {
|
||||||
|
try backgroundContext.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tagExists(name: String, in context: NSManagedObjectContext) -> Bool {
|
func saveNewLabel(name: String) async throws {
|
||||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||||
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
|
|
||||||
|
try await backgroundContext.perform {
|
||||||
do {
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let count = try context.count(for: fetchRequest)
|
guard !trimmedName.isEmpty else { return }
|
||||||
return count > 0
|
|
||||||
} catch {
|
// Check if label already exists
|
||||||
return false
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let existingTags = try backgroundContext.fetch(fetchRequest)
|
||||||
|
|
||||||
|
// Only create if it doesn't exist
|
||||||
|
if existingTags.isEmpty {
|
||||||
|
let newTag = TagEntity(context: backgroundContext)
|
||||||
|
newTag.name = trimmedName
|
||||||
|
newTag.count = 1 // New label is being used immediately
|
||||||
|
|
||||||
|
try backgroundContext.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,22 +4,25 @@ import SwiftUI
|
|||||||
|
|
||||||
class OfflineSyncManager: ObservableObject, @unchecked Sendable {
|
class OfflineSyncManager: ObservableObject, @unchecked Sendable {
|
||||||
static let shared = OfflineSyncManager()
|
static let shared = OfflineSyncManager()
|
||||||
|
|
||||||
@Published var isSyncing = false
|
@Published var isSyncing = false
|
||||||
@Published var syncStatus: String?
|
@Published var syncStatus: String?
|
||||||
|
|
||||||
private let coreDataManager = CoreDataManager.shared
|
private let coreDataManager = CoreDataManager.shared
|
||||||
private let api: PAPI
|
private let api: PAPI
|
||||||
|
private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase
|
||||||
init(api: PAPI = API()) {
|
|
||||||
|
init(api: PAPI = API(),
|
||||||
|
checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) {
|
||||||
self.api = api
|
self.api = api
|
||||||
|
self.checkServerReachabilityUseCase = checkServerReachabilityUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sync Methods
|
// MARK: - Sync Methods
|
||||||
|
|
||||||
func syncOfflineBookmarks() async {
|
func syncOfflineBookmarks() async {
|
||||||
// First check if server is reachable
|
// First check if server is reachable
|
||||||
guard await ServerConnectivity.isServerReachable() else {
|
guard await checkServerReachabilityUseCase.execute() else {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
syncStatus = "Server not reachable. Cannot sync."
|
syncStatus = "Server not reachable. Cannot sync."
|
||||||
@ -121,22 +124,4 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auto Sync on Server Connectivity Changes
|
|
||||||
|
|
||||||
func startAutoSync() {
|
|
||||||
// Monitor server connectivity and auto-sync when server becomes reachable
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
forName: .serverDidBecomeAvailable,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
Task {
|
|
||||||
await self?.syncOfflineBookmarks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
114
readeck/Data/Repository/ServerInfoRepository.swift
Normal file
114
readeck/Data/Repository/ServerInfoRepository.swift
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
//
|
||||||
|
// ServerInfoRepository.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude Code
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class ServerInfoRepository: PServerInfoRepository {
|
||||||
|
private let apiClient: PInfoApiClient
|
||||||
|
private let logger = Logger.network
|
||||||
|
|
||||||
|
// Cache properties
|
||||||
|
private var cachedServerInfo: ServerInfo?
|
||||||
|
private var lastCheckTime: Date?
|
||||||
|
private let cacheTTL: TimeInterval = 30.0 // 30 seconds cache
|
||||||
|
private let rateLimitInterval: TimeInterval = 5.0 // min 5 seconds between requests
|
||||||
|
|
||||||
|
// Thread safety
|
||||||
|
private let queue = DispatchQueue(label: "com.readeck.serverInfoRepository", attributes: .concurrent)
|
||||||
|
|
||||||
|
init(apiClient: PInfoApiClient) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkServerReachability() async -> Bool {
|
||||||
|
// Check cache first
|
||||||
|
if let cached = getCachedReachability() {
|
||||||
|
logger.debug("Server reachability from cache: \(cached)")
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limiting
|
||||||
|
if isRateLimited() {
|
||||||
|
logger.debug("Server reachability check rate limited, using cached value")
|
||||||
|
return cachedServerInfo?.isReachable ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform actual check
|
||||||
|
do {
|
||||||
|
let info = try await apiClient.getServerInfo()
|
||||||
|
let serverInfo = ServerInfo(from: info)
|
||||||
|
updateCache(serverInfo: serverInfo)
|
||||||
|
logger.info("Server reachability checked: true (version: \(info.version))")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
let unreachableInfo = ServerInfo.unreachable
|
||||||
|
updateCache(serverInfo: unreachableInfo)
|
||||||
|
logger.warning("Server reachability check failed: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getServerInfo() async throws -> ServerInfo {
|
||||||
|
// Check cache first
|
||||||
|
if let cached = getCachedServerInfo() {
|
||||||
|
logger.debug("Server info from cache")
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limiting
|
||||||
|
if isRateLimited(), let cached = cachedServerInfo {
|
||||||
|
logger.debug("Server info check rate limited, using cached value")
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh info
|
||||||
|
let dto = try await apiClient.getServerInfo()
|
||||||
|
let serverInfo = ServerInfo(from: dto)
|
||||||
|
updateCache(serverInfo: serverInfo)
|
||||||
|
logger.info("Server info fetched: version \(dto.version)")
|
||||||
|
return serverInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Management
|
||||||
|
|
||||||
|
private func getCachedReachability() -> Bool? {
|
||||||
|
queue.sync {
|
||||||
|
guard let lastCheck = lastCheckTime,
|
||||||
|
Date().timeIntervalSince(lastCheck) < cacheTTL,
|
||||||
|
let cached = cachedServerInfo else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cached.isReachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCachedServerInfo() -> ServerInfo? {
|
||||||
|
queue.sync {
|
||||||
|
guard let lastCheck = lastCheckTime,
|
||||||
|
Date().timeIntervalSince(lastCheck) < cacheTTL,
|
||||||
|
let cached = cachedServerInfo else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isRateLimited() -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
guard let lastCheck = lastCheckTime else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Date().timeIntervalSince(lastCheck) < rateLimitInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCache(serverInfo: ServerInfo) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.cachedServerInfo = serverInfo
|
||||||
|
self?.lastCheckTime = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
|
|
||||||
class ServerConnectivity: ObservableObject {
|
|
||||||
private let monitor = NWPathMonitor()
|
|
||||||
private let queue = DispatchQueue.global(qos: .background)
|
|
||||||
|
|
||||||
@Published var isServerReachable = false
|
|
||||||
|
|
||||||
static let shared = ServerConnectivity()
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
startMonitoring()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startMonitoring() {
|
|
||||||
monitor.pathUpdateHandler = { [weak self] path in
|
|
||||||
if path.status == .satisfied {
|
|
||||||
// Network is available, now check server
|
|
||||||
Task {
|
|
||||||
let serverReachable = await ServerConnectivity.isServerReachable()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let wasReachable = self?.isServerReachable ?? false
|
|
||||||
self?.isServerReachable = serverReachable
|
|
||||||
|
|
||||||
// Notify when server becomes available
|
|
||||||
if !wasReachable && serverReachable {
|
|
||||||
NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self?.isServerReachable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
monitor.start(queue: queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
monitor.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the Readeck server endpoint is reachable
|
|
||||||
static func isServerReachable() async -> Bool {
|
|
||||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
|
||||||
!endpoint.isEmpty,
|
|
||||||
let url = URL(string: endpoint + "/api/health") else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "GET"
|
|
||||||
request.timeoutInterval = 5.0 // 5 second timeout
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
return httpResponse.statusCode == 200
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fallback: try basic endpoint if health endpoint doesn't exist
|
|
||||||
return await isBasicEndpointReachable()
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isBasicEndpointReachable() async -> Bool {
|
|
||||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
|
||||||
!endpoint.isEmpty,
|
|
||||||
let url = URL(string: endpoint) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "HEAD"
|
|
||||||
request.timeoutInterval = 3.0
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
return httpResponse.statusCode < 500
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("Server connectivity check failed: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
readeck/Domain/Model/Annotation.swift
Normal file
19
readeck/Domain/Model/Annotation.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Annotation: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let text: String
|
||||||
|
let created: String
|
||||||
|
let startOffset: Int
|
||||||
|
let endOffset: Int
|
||||||
|
let startSelector: String
|
||||||
|
let endSelector: String
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: Annotation, rhs: Annotation) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
readeck/Domain/Model/ServerInfo.swift
Normal file
21
readeck/Domain/Model/ServerInfo.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ServerInfo {
|
||||||
|
let version: String
|
||||||
|
let buildDate: String?
|
||||||
|
let userAgent: String?
|
||||||
|
let isReachable: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ServerInfo {
|
||||||
|
init(from dto: ServerInfoDto) {
|
||||||
|
self.version = dto.version
|
||||||
|
self.buildDate = dto.buildDate
|
||||||
|
self.userAgent = dto.userAgent
|
||||||
|
self.isReachable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
static var unreachable: ServerInfo {
|
||||||
|
ServerInfo(version: "", buildDate: nil, userAgent: nil, isReachable: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
|
|||||||
4
readeck/Domain/Protocols/PAnnotationsRepository.swift
Normal file
4
readeck/Domain/Protocols/PAnnotationsRepository.swift
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
protocol PAnnotationsRepository {
|
||||||
|
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
||||||
|
}
|
||||||
@ -3,4 +3,5 @@ import Foundation
|
|||||||
protocol PLabelsRepository {
|
protocol PLabelsRepository {
|
||||||
func getLabels() async throws -> [BookmarkLabel]
|
func getLabels() async throws -> [BookmarkLabel]
|
||||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
|
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
|
||||||
|
func saveNewLabel(name: String) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
10
readeck/Domain/Protocols/PServerInfoRepository.swift
Normal file
10
readeck/Domain/Protocols/PServerInfoRepository.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
//
|
||||||
|
// PServerInfoRepository.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude Code
|
||||||
|
|
||||||
|
protocol PServerInfoRepository {
|
||||||
|
func checkServerReachability() async -> Bool
|
||||||
|
func getServerInfo() async throws -> ServerInfo
|
||||||
|
}
|
||||||
28
readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift
Normal file
28
readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// CheckServerReachabilityUseCase.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude Code
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PCheckServerReachabilityUseCase {
|
||||||
|
func execute() async -> Bool
|
||||||
|
func getServerInfo() async throws -> ServerInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
class CheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
|
||||||
|
private let repository: PServerInfoRepository
|
||||||
|
|
||||||
|
init(repository: PServerInfoRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute() async -> Bool {
|
||||||
|
return await repository.checkServerReachability()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getServerInfo() async throws -> ServerInfo {
|
||||||
|
return try await repository.getServerInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
17
readeck/Domain/UseCase/CreateLabelUseCase.swift
Normal file
17
readeck/Domain/UseCase/CreateLabelUseCase.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PCreateLabelUseCase {
|
||||||
|
func execute(name: String) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreateLabelUseCase: PCreateLabelUseCase {
|
||||||
|
private let labelsRepository: PLabelsRepository
|
||||||
|
|
||||||
|
init(labelsRepository: PLabelsRepository) {
|
||||||
|
self.labelsRepository = labelsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(name: String) async throws {
|
||||||
|
try await labelsRepository.saveNewLabel(name: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
readeck/Domain/UseCase/DeleteAnnotationUseCase.swift
Normal file
17
readeck/Domain/UseCase/DeleteAnnotationUseCase.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PDeleteAnnotationUseCase {
|
||||||
|
func execute(bookmarkId: String, annotationId: String) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteAnnotationUseCase: PDeleteAnnotationUseCase {
|
||||||
|
private let repository: PAnnotationsRepository
|
||||||
|
|
||||||
|
init(repository: PAnnotationsRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkId: String, annotationId: String) async throws {
|
||||||
|
try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift
Normal file
17
readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PGetBookmarkAnnotationsUseCase {
|
||||||
|
func execute(bookmarkId: String) async throws -> [Annotation]
|
||||||
|
}
|
||||||
|
|
||||||
|
class GetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
|
||||||
|
private let repository: PAnnotationsRepository
|
||||||
|
|
||||||
|
init(repository: PAnnotationsRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkId: String) async throws -> [Annotation] {
|
||||||
|
return try await repository.fetchAnnotations(bookmarkId: bookmarkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,11 +40,11 @@
|
|||||||
"Tags" = "Labels";
|
"Tags" = "Labels";
|
||||||
|
|
||||||
/* Settings Sections */
|
/* Settings Sections */
|
||||||
"Font Settings" = "Schriftart-Einstellungen";
|
"Font Settings" = "Schriftart";
|
||||||
"Appearance" = "Darstellung";
|
"Appearance" = "Darstellung";
|
||||||
"Cache Settings" = "Cache-Einstellungen";
|
"Cache Settings" = "Cache";
|
||||||
"General Settings" = "Allgemeine Einstellungen";
|
"General Settings" = "Allgemein";
|
||||||
"Server Settings" = "Server-Einstellungen";
|
"Server Settings" = "Server";
|
||||||
"Server Connection" = "Server-Verbindung";
|
"Server Connection" = "Server-Verbindung";
|
||||||
"Open external links in" = "Öffne externe Links in";
|
"Open external links in" = "Öffne externe Links in";
|
||||||
"In App Browser" = "In App Browser";
|
"In App Browser" = "In App Browser";
|
||||||
@ -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).";
|
||||||
@ -67,7 +70,7 @@
|
|||||||
"Critical" = "Kritisch";
|
"Critical" = "Kritisch";
|
||||||
"Debug" = "Debug";
|
"Debug" = "Debug";
|
||||||
"DEBUG BUILD" = "DEBUG BUILD";
|
"DEBUG BUILD" = "DEBUG BUILD";
|
||||||
"Debug Settings" = "Debug-Einstellungen";
|
"Debug Settings" = "Debug";
|
||||||
"Delete" = "Löschen";
|
"Delete" = "Löschen";
|
||||||
"Delete Bookmark" = "Lesezeichen löschen";
|
"Delete Bookmark" = "Lesezeichen löschen";
|
||||||
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
|
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
|
||||||
@ -80,13 +83,13 @@
|
|||||||
"Finished reading?" = "Fertig gelesen?";
|
"Finished reading?" = "Fertig gelesen?";
|
||||||
"Font" = "Schrift";
|
"Font" = "Schrift";
|
||||||
"Font family" = "Schriftart";
|
"Font family" = "Schriftart";
|
||||||
"Font Settings" = "Schrift-Einstellungen";
|
"Font Settings" = "Schrift";
|
||||||
"Font size" = "Schriftgröße";
|
"Font size" = "Schriftgröße";
|
||||||
"From Bremen with 💚" = "Aus Bremen mit 💚";
|
"From Bremen with 💚" = "Aus Bremen mit 💚";
|
||||||
"General" = "Allgemein";
|
"General" = "Allgemein";
|
||||||
"Global Level" = "Globales Level";
|
"Global Level" = "Globales Level";
|
||||||
"Global Minimum Level" = "Globales Minimum-Level";
|
"Global Minimum Level" = "Globales Minimum-Level";
|
||||||
"Global Settings" = "Globale Einstellungen";
|
"Global Settings" = "Global";
|
||||||
"https://example.com" = "https://example.com";
|
"https://example.com" = "https://example.com";
|
||||||
"https://readeck.example.com" = "https://readeck.example.com";
|
"https://readeck.example.com" = "https://readeck.example.com";
|
||||||
"Include Source Location" = "Quellort einschließen";
|
"Include Source Location" = "Quellort einschließen";
|
||||||
@ -105,6 +108,8 @@
|
|||||||
"More" = "Mehr";
|
"More" = "Mehr";
|
||||||
"New Bookmark" = "Neues Lesezeichen";
|
"New Bookmark" = "Neues Lesezeichen";
|
||||||
"No articles in the queue" = "Keine Artikel in der Warteschlange";
|
"No articles in the queue" = "Keine Artikel in der Warteschlange";
|
||||||
|
"open_url" = "%@ öffnen";
|
||||||
|
"open_original_page" = "Originalseite öffnen";
|
||||||
"No bookmarks" = "Keine Lesezeichen";
|
"No bookmarks" = "Keine Lesezeichen";
|
||||||
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
|
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
|
||||||
"No bookmarks found." = "Keine Lesezeichen gefunden.";
|
"No bookmarks found." = "Keine Lesezeichen gefunden.";
|
||||||
|
|||||||
@ -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).";
|
||||||
@ -101,6 +104,8 @@
|
|||||||
"More" = "More";
|
"More" = "More";
|
||||||
"New Bookmark" = "New Bookmark";
|
"New Bookmark" = "New Bookmark";
|
||||||
"No articles in the queue" = "No articles in the queue";
|
"No articles in the queue" = "No articles in the queue";
|
||||||
|
"open_url" = "Open %@";
|
||||||
|
"open_original_page" = "Open original page";
|
||||||
"No bookmarks" = "No bookmarks";
|
"No bookmarks" = "No bookmarks";
|
||||||
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
||||||
"No bookmarks found." = "No bookmarks found.";
|
"No bookmarks found." = "No bookmarks found.";
|
||||||
|
|||||||
@ -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,29 @@ 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)
|
context: viewContext,
|
||||||
},
|
onAddCustomTag: {
|
||||||
onRemoveLabel: { label in
|
viewModel.addCustomTag()
|
||||||
viewModel.removeLabel(label)
|
},
|
||||||
}
|
onToggleLabel: { label in
|
||||||
)
|
viewModel.toggleLabel(label)
|
||||||
|
},
|
||||||
|
onRemoveLabel: { label in
|
||||||
|
viewModel.removeLabel(label)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@ -8,6 +8,8 @@ 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 createLabelUseCase = DefaultUseCaseFactory.shared.makeCreateLabelUseCase()
|
||||||
|
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
|
||||||
|
|
||||||
// MARK: - Form Data
|
// MARK: - Form Data
|
||||||
var url: String = ""
|
var url: String = ""
|
||||||
@ -60,12 +62,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 }
|
||||||
@ -79,17 +88,22 @@ class AddBookmarkViewModel {
|
|||||||
func addCustomTag() {
|
func addCustomTag() {
|
||||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
|
|
||||||
let lowercased = trimmed.lowercased()
|
let lowercased = trimmed.lowercased()
|
||||||
let allExisting = Set(allLabels.map { $0.name.lowercased() })
|
let allExisting = Set(allLabels.map { $0.name.lowercased() })
|
||||||
let allSelected = Set(selectedLabels.map { $0.lowercased() })
|
let allSelected = Set(selectedLabels.map { $0.lowercased() })
|
||||||
|
|
||||||
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
||||||
// Tag already exists, don't add
|
// Tag already exists, don't add
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
selectedLabels.insert(trimmed)
|
selectedLabels.insert(trimmed)
|
||||||
searchText = ""
|
searchText = ""
|
||||||
|
|
||||||
|
// Save new label to Core Data so it's available next time
|
||||||
|
Task {
|
||||||
|
try? await createLabelUseCase.execute(name: trimmed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,19 +8,24 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class AppViewModel: ObservableObject {
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
class AppViewModel {
|
||||||
private let settingsRepository = SettingsRepository()
|
private let settingsRepository = SettingsRepository()
|
||||||
private let logoutUseCase: LogoutUseCase
|
private let factory: UseCaseFactory
|
||||||
|
private let syncTagsUseCase: PSyncTagsUseCase
|
||||||
@Published var hasFinishedSetup: Bool = true
|
|
||||||
|
var hasFinishedSetup: Bool = true
|
||||||
init(logoutUseCase: LogoutUseCase = LogoutUseCase()) {
|
var isServerReachable: Bool = false
|
||||||
self.logoutUseCase = logoutUseCase
|
|
||||||
|
private var lastAppStartTagSyncTime: Date?
|
||||||
|
|
||||||
|
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
|
self.factory = factory
|
||||||
|
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
|
||||||
setupNotificationObservers()
|
setupNotificationObservers()
|
||||||
|
|
||||||
Task {
|
loadSetupStatus()
|
||||||
await loadSetupStatus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObservers() {
|
private func setupNotificationObservers() {
|
||||||
@ -29,7 +34,7 @@ class AppViewModel: ObservableObject {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
Task {
|
Task { @MainActor in
|
||||||
await self?.handleUnauthorizedResponse()
|
await self?.handleUnauthorizedResponse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -39,19 +44,17 @@ class AppViewModel: ObservableObject {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.loadSetupStatus()
|
Task { @MainActor in
|
||||||
|
self?.loadSetupStatus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleUnauthorizedResponse() async {
|
private func handleUnauthorizedResponse() async {
|
||||||
print("AppViewModel: Handling 401 Unauthorized - logging out user")
|
print("AppViewModel: Handling 401 Unauthorized - logging out user")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Führe den Logout durch
|
try await factory.makeLogoutUseCase().execute()
|
||||||
try await logoutUseCase.execute()
|
|
||||||
|
|
||||||
// Update UI state
|
|
||||||
loadSetupStatus()
|
loadSetupStatus()
|
||||||
|
|
||||||
print("AppViewModel: User successfully logged out due to 401 error")
|
print("AppViewModel: User successfully logged out due to 401 error")
|
||||||
@ -60,11 +63,35 @@ class AppViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadSetupStatus() {
|
private func loadSetupStatus() {
|
||||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func onAppResume() async {
|
||||||
|
await checkServerReachability()
|
||||||
|
await syncTagsOnAppStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkServerReachability() async {
|
||||||
|
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncTagsOnAppStart() async {
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
// Check if last sync was less than 2 minutes ago
|
||||||
|
if let lastSync = lastAppStartTagSyncTime,
|
||||||
|
now.timeIntervalSince(lastSync) < 120 {
|
||||||
|
print("AppViewModel: Skipping tag sync - last sync was less than 2 minutes ago")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync tags from server to Core Data
|
||||||
|
print("AppViewModel: Syncing tags on app start")
|
||||||
|
try? await syncTagsUseCase.execute()
|
||||||
|
lastAppStartTagSyncTime = now
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|||||||
45
readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift
Normal file
45
readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AnnotationColorOverlay: View {
|
||||||
|
let onColorSelected: (AnnotationColor) -> Void
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Constants.annotationColors, id: \.self) { color in
|
||||||
|
ColorButton(color: color, onTap: onColorSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ColorButton: View {
|
||||||
|
let color: AnnotationColor
|
||||||
|
let onTap: (AnnotationColor) -> Void
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: { onTap(color) }) {
|
||||||
|
Circle()
|
||||||
|
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AnnotationColorOverlay { color in
|
||||||
|
print("Selected: \(color)")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
63
readeck/UI/BookmarkDetail/AnnotationColorPicker.swift
Normal file
63
readeck/UI/BookmarkDetail/AnnotationColorPicker.swift
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AnnotationColorPicker: View {
|
||||||
|
let selectedText: String
|
||||||
|
let onColorSelected: (AnnotationColor) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Highlight Text")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(selectedText)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(3)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
|
||||||
|
Text("Select Color")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(Constants.annotationColors, id: \.self) { color in
|
||||||
|
ColorButton(color: color, onTap: handleColorSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleColorSelection(_ color: AnnotationColor) {
|
||||||
|
onColorSelected(color)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ColorButton: View {
|
||||||
|
let color: AnnotationColor
|
||||||
|
let onTap: (AnnotationColor) -> Void
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: { onTap(color) }) {
|
||||||
|
Circle()
|
||||||
|
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.primary.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
readeck/UI/BookmarkDetail/AnnotationsListView.swift
Normal file
131
readeck/UI/BookmarkDetail/AnnotationsListView.swift
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AnnotationsListView: View {
|
||||||
|
let bookmarkId: String
|
||||||
|
@State private var viewModel = AnnotationsListViewModel()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
var onAnnotationTap: ((String) -> Void)?
|
||||||
|
|
||||||
|
enum ViewState {
|
||||||
|
case loading
|
||||||
|
case empty
|
||||||
|
case loaded([Annotation])
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var viewState: ViewState {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
return .loading
|
||||||
|
} else if let error = viewModel.errorMessage, viewModel.showErrorAlert {
|
||||||
|
return .error(error)
|
||||||
|
} else if viewModel.annotations.isEmpty {
|
||||||
|
return .empty
|
||||||
|
} else {
|
||||||
|
return .loaded(viewModel.annotations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
switch viewState {
|
||||||
|
case .loading:
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
case .empty:
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Annotations",
|
||||||
|
systemImage: "pencil.slash",
|
||||||
|
description: Text("This bookmark has no annotations yet.")
|
||||||
|
)
|
||||||
|
|
||||||
|
case .loaded(let annotations):
|
||||||
|
ForEach(annotations) { annotation in
|
||||||
|
Button(action: {
|
||||||
|
onAnnotationTap?(annotation.id)
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if !annotation.text.isEmpty {
|
||||||
|
Text(annotation.text)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(formatDate(annotation.created))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotation.id)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .error:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Annotations")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadAnnotations(for: bookmarkId)
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: $viewModel.showErrorAlert) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ dateString: String) -> String {
|
||||||
|
let isoFormatter = ISO8601DateFormatter()
|
||||||
|
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let isoFormatterNoMillis = ISO8601DateFormatter()
|
||||||
|
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
|
||||||
|
var date: Date?
|
||||||
|
if let parsedDate = isoFormatter.date(from: dateString) {
|
||||||
|
date = parsedDate
|
||||||
|
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
|
||||||
|
date = parsedDate
|
||||||
|
}
|
||||||
|
if let date = date {
|
||||||
|
let displayFormatter = DateFormatter()
|
||||||
|
displayFormatter.dateStyle = .medium
|
||||||
|
displayFormatter.timeStyle = .short
|
||||||
|
displayFormatter.locale = .autoupdatingCurrent
|
||||||
|
return displayFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
return dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
AnnotationsListView(bookmarkId: "123")
|
||||||
|
}
|
||||||
|
}
|
||||||
42
readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift
Normal file
42
readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class AnnotationsListViewModel {
|
||||||
|
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
|
||||||
|
private let deleteAnnotationUseCase: PDeleteAnnotationUseCase
|
||||||
|
|
||||||
|
var annotations: [Annotation] = []
|
||||||
|
var isLoading = false
|
||||||
|
var errorMessage: String?
|
||||||
|
var showErrorAlert = false
|
||||||
|
|
||||||
|
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
|
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
|
||||||
|
self.deleteAnnotationUseCase = factory.makeDeleteAnnotationUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func loadAnnotations(for bookmarkId: String) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
annotations = try await getAnnotationsUseCase.execute(bookmarkId: bookmarkId)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to load annotations"
|
||||||
|
showErrorAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async {
|
||||||
|
do {
|
||||||
|
try await deleteAnnotationUseCase.execute(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||||
|
annotations.removeAll { $0.id == annotationId }
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to delete annotation"
|
||||||
|
showErrorAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,18 +29,19 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
@State private var initialContentEndPosition: CGFloat = 0
|
@State private var initialContentEndPosition: CGFloat = 0
|
||||||
@State private var showingFontSettings = false
|
@State private var showingFontSettings = false
|
||||||
@State private var showingLabelsSheet = false
|
@State private var showingLabelsSheet = false
|
||||||
|
@State private var showingAnnotationsSheet = false
|
||||||
@State private var readingProgress: Double = 0.0
|
@State private var readingProgress: Double = 0.0
|
||||||
@State private var lastSentProgress: Double = 0.0
|
@State private var lastSentProgress: Double = 0.0
|
||||||
@State private var showJumpToProgressButton: Bool = false
|
@State private var showJumpToProgressButton: Bool = false
|
||||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||||
@State private var showingImageViewer = false
|
@State private var showingImageViewer = false
|
||||||
|
|
||||||
// MARK: - Envs
|
// MARK: - Envs
|
||||||
|
|
||||||
@EnvironmentObject var playerUIState: PlayerUIState
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
private let headerHeight: CGFloat = 360
|
private let headerHeight: CGFloat = 360
|
||||||
|
|
||||||
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
|
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
|
||||||
@ -86,6 +87,30 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
if webViewHeight != height {
|
if webViewHeight != height {
|
||||||
webViewHeight = height
|
webViewHeight = height
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
selectedAnnotationId: viewModel.selectedAnnotationId,
|
||||||
|
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
|
||||||
|
Task {
|
||||||
|
await viewModel.createAnnotation(
|
||||||
|
bookmarkId: bookmarkId,
|
||||||
|
color: color,
|
||||||
|
text: text,
|
||||||
|
startOffset: startOffset,
|
||||||
|
endOffset: endOffset,
|
||||||
|
startSelector: startSelector,
|
||||||
|
endSelector: endSelector
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScrollToPosition: { position in
|
||||||
|
// Calculate scroll position: add header height and webview offset
|
||||||
|
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
|
||||||
|
let targetPosition = imageHeight + position
|
||||||
|
|
||||||
|
// Scroll to the annotation
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
scrollPosition = ScrollPosition(y: targetPosition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
@ -101,7 +126,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -220,6 +245,12 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
Image(systemName: "tag")
|
Image(systemName: "tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showingAnnotationsSheet = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "pencil.line")
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingFontSettings = true
|
showingFontSettings = true
|
||||||
}) {
|
}) {
|
||||||
@ -252,6 +283,11 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
.sheet(isPresented: $showingLabelsSheet) {
|
.sheet(isPresented: $showingLabelsSheet) {
|
||||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAnnotationsSheet) {
|
||||||
|
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
|
||||||
|
viewModel.selectedAnnotationId = annotationId
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingImageViewer) {
|
.sheet(isPresented: $showingImageViewer) {
|
||||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
||||||
}
|
}
|
||||||
@ -271,9 +307,20 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: showingAnnotationsSheet) { _, isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
// Reload bookmark detail when labels sheet is dismissed
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: viewModel.readProgress) { _, progress in
|
.onChange(of: viewModel.readProgress) { _, progress in
|
||||||
showJumpToProgressButton = progress > 0 && progress < 100
|
showJumpToProgressButton = progress > 0 && progress < 100
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
|
||||||
|
// Trigger WebView reload when annotation is selected
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||||
await viewModel.loadArticleContent(id: bookmarkId)
|
await viewModel.loadArticleContent(id: bookmarkId)
|
||||||
@ -355,7 +402,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -409,7 +456,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||||
}) {
|
}) {
|
||||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct BookmarkDetailView2: View {
|
|||||||
@State private var initialContentEndPosition: CGFloat = 0
|
@State private var initialContentEndPosition: CGFloat = 0
|
||||||
@State private var showingFontSettings = false
|
@State private var showingFontSettings = false
|
||||||
@State private var showingLabelsSheet = false
|
@State private var showingLabelsSheet = false
|
||||||
|
@State private var showingAnnotationsSheet = false
|
||||||
@State private var readingProgress: Double = 0.0
|
@State private var readingProgress: Double = 0.0
|
||||||
@State private var lastSentProgress: Double = 0.0
|
@State private var lastSentProgress: Double = 0.0
|
||||||
@State private var showJumpToProgressButton: Bool = false
|
@State private var showJumpToProgressButton: Bool = false
|
||||||
@ -50,6 +51,11 @@ struct BookmarkDetailView2: View {
|
|||||||
.sheet(isPresented: $showingLabelsSheet) {
|
.sheet(isPresented: $showingLabelsSheet) {
|
||||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAnnotationsSheet) {
|
||||||
|
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
|
||||||
|
viewModel.selectedAnnotationId = annotationId
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingImageViewer) {
|
.sheet(isPresented: $showingImageViewer) {
|
||||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
||||||
}
|
}
|
||||||
@ -67,9 +73,19 @@ struct BookmarkDetailView2: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: showingAnnotationsSheet) { _, isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: viewModel.readProgress) { _, progress in
|
.onChange(of: viewModel.readProgress) { _, progress in
|
||||||
showJumpToProgressButton = progress > 0 && progress < 100
|
showJumpToProgressButton = progress > 0 && progress < 100
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
|
||||||
|
// Trigger WebView reload when annotation is selected
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||||
await viewModel.loadArticleContent(id: bookmarkId)
|
await viewModel.loadArticleContent(id: bookmarkId)
|
||||||
@ -254,6 +270,14 @@ struct BookmarkDetailView2: View {
|
|||||||
Image(systemName: "tag")
|
Image(systemName: "tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if viewModel.hasAnnotations {
|
||||||
|
Button(action: {
|
||||||
|
showingAnnotationsSheet = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "pencil.line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingFontSettings = true
|
showingFontSettings = true
|
||||||
}) {
|
}) {
|
||||||
@ -387,7 +411,7 @@ struct BookmarkDetailView2: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||||
}) {
|
}) {
|
||||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@ -437,6 +461,30 @@ struct BookmarkDetailView2: View {
|
|||||||
if webViewHeight != height {
|
if webViewHeight != height {
|
||||||
webViewHeight = height
|
webViewHeight = height
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
selectedAnnotationId: viewModel.selectedAnnotationId,
|
||||||
|
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
|
||||||
|
Task {
|
||||||
|
await viewModel.createAnnotation(
|
||||||
|
bookmarkId: bookmarkId,
|
||||||
|
color: color,
|
||||||
|
text: text,
|
||||||
|
startOffset: startOffset,
|
||||||
|
endOffset: endOffset,
|
||||||
|
startSelector: startSelector,
|
||||||
|
endSelector: endSelector
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScrollToPosition: { position in
|
||||||
|
// Calculate scroll position: add header height and webview offset
|
||||||
|
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
|
||||||
|
let targetPosition = imageHeight + position
|
||||||
|
|
||||||
|
// Scroll to the annotation
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
scrollPosition = ScrollPosition(y: targetPosition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
@ -453,7 +501,7 @@ struct BookmarkDetailView2: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@ -8,7 +8,8 @@ class BookmarkDetailViewModel {
|
|||||||
private let loadSettingsUseCase: PLoadSettingsUseCase
|
private let loadSettingsUseCase: PLoadSettingsUseCase
|
||||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||||
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
|
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
|
||||||
|
private let api: PAPI
|
||||||
|
|
||||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||||
var articleContent: String = ""
|
var articleContent: String = ""
|
||||||
var articleParagraphs: [String] = []
|
var articleParagraphs: [String] = []
|
||||||
@ -18,7 +19,9 @@ class BookmarkDetailViewModel {
|
|||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var settings: Settings?
|
var settings: Settings?
|
||||||
var readProgress: Int = 0
|
var readProgress: Int = 0
|
||||||
|
var selectedAnnotationId: String?
|
||||||
|
var hasAnnotations: Bool = false
|
||||||
|
|
||||||
private var factory: UseCaseFactory?
|
private var factory: UseCaseFactory?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>()
|
private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>()
|
||||||
@ -28,8 +31,9 @@ class BookmarkDetailViewModel {
|
|||||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||||
|
self.api = API()
|
||||||
self.factory = factory
|
self.factory = factory
|
||||||
|
|
||||||
readProgressSubject
|
readProgressSubject
|
||||||
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
|
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||||
.sink { [weak self] (id, progress, anchor) in
|
.sink { [weak self] (id, progress, anchor) in
|
||||||
@ -67,23 +71,26 @@ class BookmarkDetailViewModel {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func loadArticleContent(id: String) async {
|
func loadArticleContent(id: String) async {
|
||||||
isLoadingArticle = true
|
isLoadingArticle = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
|
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
|
||||||
processArticleContent()
|
processArticleContent()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error loading article"
|
errorMessage = "Error loading article"
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingArticle = false
|
isLoadingArticle = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processArticleContent() {
|
private func processArticleContent() {
|
||||||
let paragraphs = articleContent
|
let paragraphs = articleContent
|
||||||
.components(separatedBy: .newlines)
|
.components(separatedBy: .newlines)
|
||||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
|
||||||
articleParagraphs = paragraphs
|
articleParagraphs = paragraphs
|
||||||
|
|
||||||
|
// Check if article contains annotations
|
||||||
|
hasAnnotations = articleContent.contains("<rd-annotation")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -137,4 +144,22 @@ class BookmarkDetailViewModel {
|
|||||||
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
|
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
|
||||||
readProgressSubject.send((id, progress, anchor))
|
readProgressSubject.send((id, progress, anchor))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async {
|
||||||
|
do {
|
||||||
|
let annotation = try await api.createAnnotation(
|
||||||
|
bookmarkId: bookmarkId,
|
||||||
|
color: color,
|
||||||
|
startOffset: startOffset,
|
||||||
|
endOffset: endOffset,
|
||||||
|
startSelector: startSelector,
|
||||||
|
endSelector: endSelector
|
||||||
|
)
|
||||||
|
print("✅ Annotation created: \(annotation.id)")
|
||||||
|
} catch {
|
||||||
|
print("❌ Failed to create annotation: \(error)")
|
||||||
|
errorMessage = "Error creating annotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,36 @@ 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,
|
||||||
|
context: viewContext,
|
||||||
|
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
|
||||||
|
|||||||
@ -254,7 +254,7 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||||
}
|
}
|
||||||
@ -335,7 +335,7 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,9 +22,12 @@ class BookmarksViewModel {
|
|||||||
var showingAddBookmarkFromShare = false
|
var showingAddBookmarkFromShare = false
|
||||||
var shareURL = ""
|
var shareURL = ""
|
||||||
var shareTitle = ""
|
var shareTitle = ""
|
||||||
|
|
||||||
// Undo delete functionality
|
// Undo delete functionality
|
||||||
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
||||||
|
|
||||||
|
// Prevent concurrent updates
|
||||||
|
private var isUpdating = false
|
||||||
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@ -104,15 +107,19 @@ class BookmarksViewModel {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
|
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
|
||||||
|
guard !isUpdating else { return }
|
||||||
|
isUpdating = true
|
||||||
|
defer { isUpdating = false }
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
currentState = state
|
currentState = state
|
||||||
currentType = type
|
currentType = type
|
||||||
currentTag = tag
|
currentTag = tag
|
||||||
|
|
||||||
offset = 0
|
offset = 0
|
||||||
hasMoreData = true
|
hasMoreData = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||||
state: state,
|
state: state,
|
||||||
@ -142,18 +149,20 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
// Don't clear bookmarks on error - keep existing data visible
|
// Don't clear bookmarks on error - keep existing data visible
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
isInitialLoading = false
|
isInitialLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadMoreBookmarks() async {
|
func loadMoreBookmarks() async {
|
||||||
guard !isLoading && hasMoreData else { return } // prevent multiple loads
|
guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads
|
||||||
|
isUpdating = true
|
||||||
|
defer { isUpdating = false }
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
offset += limit // inc. offset
|
offset += limit // inc. offset
|
||||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||||
@ -181,7 +190,7 @@ class BookmarksViewModel {
|
|||||||
errorMessage = "Error loading more bookmarks"
|
errorMessage = "Error loading more bookmarks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,53 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct Constants {
|
struct Constants {
|
||||||
// Empty for now - can be used for other constants in the future
|
// Annotation colors
|
||||||
|
static let annotationColors: [AnnotationColor] = [.yellow, .green, .blue, .red]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnnotationColor: String, CaseIterable, Codable {
|
||||||
|
case yellow = "yellow"
|
||||||
|
case green = "green"
|
||||||
|
case blue = "blue"
|
||||||
|
case red = "red"
|
||||||
|
|
||||||
|
// Base hex color for buttons and overlays
|
||||||
|
var hexColor: String {
|
||||||
|
switch self {
|
||||||
|
case .yellow: return "#D4A843"
|
||||||
|
case .green: return "#6FB546"
|
||||||
|
case .blue: return "#4A9BB8"
|
||||||
|
case .red: return "#C84848"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGB values for SwiftUI Color
|
||||||
|
private var rgb: (red: Double, green: Double, blue: Double) {
|
||||||
|
switch self {
|
||||||
|
case .yellow: return (212, 168, 67)
|
||||||
|
case .green: return (111, 181, 70)
|
||||||
|
case .blue: return (74, 155, 184)
|
||||||
|
case .red: return (200, 72, 72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func swiftUIColor(isDark: Bool) -> Color {
|
||||||
|
let (r, g, b) = rgb
|
||||||
|
return Color(red: r/255, green: g/255, blue: b/255)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS rgba string for JavaScript (for highlighting)
|
||||||
|
func cssColor(isDark: Bool) -> String {
|
||||||
|
let (r, g, b) = rgb
|
||||||
|
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), 0.3)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS rgba string with custom opacity
|
||||||
|
func cssColorWithOpacity(_ opacity: Double) -> String {
|
||||||
|
let (r, g, b) = rgb
|
||||||
|
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), \(opacity))"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
330
readeck/UI/Components/CoreDataTagManagementView.swift
Normal file
330
readeck/UI/Components/CoreDataTagManagementView.swift
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
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 availableLabelsTitle: String?
|
||||||
|
let context: NSManagedObjectContext
|
||||||
|
|
||||||
|
// MARK: - Callbacks
|
||||||
|
|
||||||
|
let onAddCustomTag: () -> Void
|
||||||
|
let onToggleLabel: (String) -> Void
|
||||||
|
let onRemoveLabel: (String) -> Void
|
||||||
|
|
||||||
|
// MARK: - FetchRequest
|
||||||
|
|
||||||
|
@FetchRequest
|
||||||
|
private var tagEntities: FetchedResults<TagEntity>
|
||||||
|
|
||||||
|
// MARK: - Search State
|
||||||
|
|
||||||
|
@State private var searchResults: [TagEntity] = []
|
||||||
|
@State private var isSearchActive: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
selectedLabels: Set<String>,
|
||||||
|
searchText: Binding<String>,
|
||||||
|
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
||||||
|
fetchLimit: Int? = nil,
|
||||||
|
sortOrder: TagSortOrder = .byCount,
|
||||||
|
availableLabelsTitle: String? = nil,
|
||||||
|
context: NSManagedObjectContext,
|
||||||
|
onAddCustomTag: @escaping () -> Void,
|
||||||
|
onToggleLabel: @escaping (String) -> Void,
|
||||||
|
onRemoveLabel: @escaping (String) -> Void
|
||||||
|
) {
|
||||||
|
self.selectedLabelsSet = selectedLabels
|
||||||
|
self.searchText = searchText
|
||||||
|
self.searchFieldFocus = searchFieldFocus
|
||||||
|
self.sortOrder = sortOrder
|
||||||
|
self.availableLabelsTitle = availableLabelsTitle
|
||||||
|
self.context = context
|
||||||
|
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
|
||||||
|
}
|
||||||
|
.onChange(of: searchText.wrappedValue) { oldValue, newValue in
|
||||||
|
performSearch(query: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Components
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var searchField: some View {
|
||||||
|
TextField("Search or add new label...", 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 label:")
|
||||||
|
.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 ? (availableLabelsTitle ?? "Available labels") : "Search results")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
if !searchText.wrappedValue.isEmpty {
|
||||||
|
Text("(\(filteredTagsCount) found)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if availableUnselectedTagsCount == 0 {
|
||||||
|
// Show "All labels selected" only if there are actually filtered results
|
||||||
|
// Otherwise show "No labels found" for empty search results
|
||||||
|
if filteredTagsCount > 0 {
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text("All labels selected")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
} else if !searchText.wrappedValue.isEmpty {
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No labels found")
|
||||||
|
.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
|
||||||
|
) {
|
||||||
|
// Use searchResults when search is active, otherwise use tagEntities
|
||||||
|
let tagsToDisplay = isSearchActive ? searchResults : Array(tagEntities)
|
||||||
|
|
||||||
|
ForEach(tagsToDisplay, id: \.objectID) { entity in
|
||||||
|
if let name = entity.name {
|
||||||
|
// When searching, show all results (already filtered by predicate)
|
||||||
|
// When not searching, filter with shouldShowTag()
|
||||||
|
let shouldShow = isSearchActive ? !selectedLabelsSet.contains(name) : shouldShowTag(name)
|
||||||
|
|
||||||
|
if shouldShow {
|
||||||
|
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 isSearchActive {
|
||||||
|
return searchResults.count
|
||||||
|
} else 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 {
|
||||||
|
if isSearchActive {
|
||||||
|
return searchResults.filter { entity in
|
||||||
|
guard let name = entity.name else { return false }
|
||||||
|
return !selectedLabelsSet.contains(name)
|
||||||
|
}.count
|
||||||
|
} else {
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSearch(query: String) {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
isSearchActive = false
|
||||||
|
searchResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search directly in Core Data without fetchLimit
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", query)
|
||||||
|
|
||||||
|
// Use same sort order as main fetch
|
||||||
|
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
|
||||||
|
|
||||||
|
// NO fetchLimit - search ALL tags in database
|
||||||
|
searchResults = (try? context.fetch(fetchRequest)) ?? []
|
||||||
|
isSearchActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var selectedLabels: some View {
|
||||||
|
if !selectedLabelsSet.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Selected labels")
|
||||||
|
.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
|
||||||
|
|
||||||
@ -214,7 +218,7 @@ struct TagManagementView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var labelsScrollView: some View {
|
private var labelsScrollView: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
ForEach(rowLabels, id: \.id) { label in
|
ForEach(rowLabels, id: \.id) { label in
|
||||||
@ -11,7 +11,10 @@ struct NativeWebView: View {
|
|||||||
let settings: Settings
|
let settings: Settings
|
||||||
let onHeightChange: (CGFloat) -> Void
|
let onHeightChange: (CGFloat) -> Void
|
||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
|
var selectedAnnotationId: String?
|
||||||
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||||
|
var onScrollToPosition: ((CGFloat) -> Void)? = nil
|
||||||
|
|
||||||
@State private var webPage = WebPage()
|
@State private var webPage = WebPage()
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
@ -20,6 +23,8 @@ struct NativeWebView: View {
|
|||||||
.scrollDisabled(true) // Disable internal scrolling
|
.scrollDisabled(true) // Disable internal scrolling
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
|
setupAnnotationMessageHandler()
|
||||||
|
setupScrollToPositionHandler()
|
||||||
}
|
}
|
||||||
.onChange(of: htmlContent) { _, _ in
|
.onChange(of: htmlContent) { _, _ in
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
@ -27,6 +32,9 @@ struct NativeWebView: View {
|
|||||||
.onChange(of: colorScheme) { _, _ in
|
.onChange(of: colorScheme) { _, _ in
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedAnnotationId) { _, _ in
|
||||||
|
loadStyledContent()
|
||||||
|
}
|
||||||
.onChange(of: webPage.isLoading) { _, isLoading in
|
.onChange(of: webPage.isLoading) { _, isLoading in
|
||||||
if !isLoading {
|
if !isLoading {
|
||||||
// Update height when content finishes loading
|
// Update height when content finishes loading
|
||||||
@ -38,6 +46,76 @@ struct NativeWebView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupAnnotationMessageHandler() {
|
||||||
|
guard let onAnnotationCreated = onAnnotationCreated else { return }
|
||||||
|
|
||||||
|
// Poll for annotation messages from JavaScript
|
||||||
|
Task { @MainActor in
|
||||||
|
let page = webPage
|
||||||
|
|
||||||
|
while true {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
|
||||||
|
|
||||||
|
let script = """
|
||||||
|
return (function() {
|
||||||
|
if (window.__pendingAnnotation) {
|
||||||
|
const data = window.__pendingAnnotation;
|
||||||
|
window.__pendingAnnotation = null;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let result = try await page.callJavaScript(script) as? [String: Any],
|
||||||
|
let color = result["color"] as? String,
|
||||||
|
let text = result["text"] as? String,
|
||||||
|
let startOffset = result["startOffset"] as? Int,
|
||||||
|
let endOffset = result["endOffset"] as? Int,
|
||||||
|
let startSelector = result["startSelector"] as? String,
|
||||||
|
let endSelector = result["endSelector"] as? String {
|
||||||
|
onAnnotationCreated(color, text, startOffset, endOffset, startSelector, endSelector)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupScrollToPositionHandler() {
|
||||||
|
guard let onScrollToPosition = onScrollToPosition else { return }
|
||||||
|
|
||||||
|
// Poll for scroll position messages from JavaScript
|
||||||
|
Task { @MainActor in
|
||||||
|
let page = webPage
|
||||||
|
|
||||||
|
while true {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
|
||||||
|
|
||||||
|
let script = """
|
||||||
|
return (function() {
|
||||||
|
if (window.__pendingScrollPosition !== undefined) {
|
||||||
|
const position = window.__pendingScrollPosition;
|
||||||
|
window.__pendingScrollPosition = undefined;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let position = try await page.callJavaScript(script) as? Double {
|
||||||
|
onScrollToPosition(CGFloat(position))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateContentHeightWithJS() async {
|
private func updateContentHeightWithJS() async {
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
@ -197,6 +275,49 @@ struct NativeWebView: View {
|
|||||||
th { font-weight: 600; }
|
th { font-weight: 600; }
|
||||||
|
|
||||||
hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; }
|
hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; }
|
||||||
|
|
||||||
|
/* Annotation Highlighting - for rd-annotation tags */
|
||||||
|
rd-annotation {
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 0;
|
||||||
|
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Yellow annotations */
|
||||||
|
rd-annotation[data-annotation-color="yellow"] {
|
||||||
|
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
|
||||||
|
}
|
||||||
|
rd-annotation[data-annotation-color="yellow"].selected {
|
||||||
|
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
|
||||||
|
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green annotations */
|
||||||
|
rd-annotation[data-annotation-color="green"] {
|
||||||
|
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
|
||||||
|
}
|
||||||
|
rd-annotation[data-annotation-color="green"].selected {
|
||||||
|
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
|
||||||
|
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blue annotations */
|
||||||
|
rd-annotation[data-annotation-color="blue"] {
|
||||||
|
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
|
||||||
|
}
|
||||||
|
rd-annotation[data-annotation-color="blue"].selected {
|
||||||
|
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
|
||||||
|
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red annotations */
|
||||||
|
rd-annotation[data-annotation-color="red"] {
|
||||||
|
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
|
||||||
|
}
|
||||||
|
rd-annotation[data-annotation-color="red"].selected {
|
||||||
|
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
|
||||||
|
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -242,6 +363,12 @@ struct NativeWebView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scheduleHeightCheck();
|
scheduleHeightCheck();
|
||||||
|
|
||||||
|
// Scroll to selected annotation
|
||||||
|
\(generateScrollToAnnotationJS())
|
||||||
|
|
||||||
|
// Text Selection and Annotation Overlay
|
||||||
|
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -273,6 +400,287 @@ struct NativeWebView: View {
|
|||||||
case .monospace: return "'SF Mono', Menlo, Monaco, monospace"
|
case .monospace: return "'SF Mono', Menlo, Monaco, monospace"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
|
||||||
|
return """
|
||||||
|
// Create annotation color overlay
|
||||||
|
(function() {
|
||||||
|
let currentSelection = null;
|
||||||
|
let currentRange = null;
|
||||||
|
let selectionTimeout = null;
|
||||||
|
|
||||||
|
// Create overlay container with arrow
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'annotation-overlay';
|
||||||
|
overlay.style.cssText = `
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create arrow/triangle pointing up with glass effect
|
||||||
|
const arrow = document.createElement('div');
|
||||||
|
arrow.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
top: -11px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
`;
|
||||||
|
overlay.appendChild(arrow);
|
||||||
|
|
||||||
|
// Create the actual content container with glass morphism effect
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.15),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||||
|
gap: 12px;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
overlay.appendChild(content);
|
||||||
|
|
||||||
|
// Add "Markierung" label
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = 'Markierung';
|
||||||
|
label.style.cssText = `
|
||||||
|
color: black;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 4px;
|
||||||
|
`;
|
||||||
|
content.appendChild(label);
|
||||||
|
|
||||||
|
// Create color buttons with solid colors
|
||||||
|
const colors = [
|
||||||
|
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
|
||||||
|
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
|
||||||
|
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
|
||||||
|
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
colors.forEach(({ name, color }) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.dataset.color = name;
|
||||||
|
btn.style.cssText = `
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${color};
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
`;
|
||||||
|
btn.addEventListener('mouseenter', () => {
|
||||||
|
btn.style.transform = 'scale(1.1)';
|
||||||
|
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
|
||||||
|
});
|
||||||
|
btn.addEventListener('mouseleave', () => {
|
||||||
|
btn.style.transform = 'scale(1)';
|
||||||
|
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', () => handleColorSelection(name));
|
||||||
|
content.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Selection change listener
|
||||||
|
document.addEventListener('selectionchange', () => {
|
||||||
|
clearTimeout(selectionTimeout);
|
||||||
|
selectionTimeout = setTimeout(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const text = selection.toString().trim();
|
||||||
|
|
||||||
|
if (text.length > 0) {
|
||||||
|
currentSelection = text;
|
||||||
|
currentRange = selection.getRangeAt(0).cloneRange();
|
||||||
|
showOverlay(selection.getRangeAt(0));
|
||||||
|
} else {
|
||||||
|
hideOverlay();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
function showOverlay(range) {
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
const scrollY = window.scrollY || window.pageYOffset;
|
||||||
|
|
||||||
|
overlay.style.display = 'block';
|
||||||
|
|
||||||
|
// Center horizontally under selection
|
||||||
|
const overlayWidth = 320; // Approximate width with label + 4 buttons
|
||||||
|
const centerX = rect.left + (rect.width / 2);
|
||||||
|
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
|
||||||
|
|
||||||
|
// Position with extra space below selection (55px instead of 70px) to bring it closer
|
||||||
|
const topPos = rect.bottom + scrollY + 55;
|
||||||
|
|
||||||
|
overlay.style.left = leftPos + 'px';
|
||||||
|
overlay.style.top = topPos + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideOverlay() {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
currentSelection = null;
|
||||||
|
currentRange = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOffset(container, offset) {
|
||||||
|
const preRange = document.createRange();
|
||||||
|
preRange.selectNodeContents(document.body);
|
||||||
|
preRange.setEnd(container, offset);
|
||||||
|
return preRange.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXPathSelector(node) {
|
||||||
|
// If node is text node, use parent element
|
||||||
|
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
||||||
|
if (!element || element === document.body) return 'body';
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
let current = element;
|
||||||
|
|
||||||
|
while (current && current !== document.body) {
|
||||||
|
const tagName = current.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Count position among siblings of same tag (1-based index)
|
||||||
|
let index = 1;
|
||||||
|
let sibling = current.previousElementSibling;
|
||||||
|
while (sibling) {
|
||||||
|
if (sibling.tagName === current.tagName) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
sibling = sibling.previousElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: tagname[index] (1-based)
|
||||||
|
path.unshift(tagName + '[' + index + ']');
|
||||||
|
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = path.join('/');
|
||||||
|
console.log('Generated selector:', selector);
|
||||||
|
return selector || 'body';
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOffsetInElement(container, offset) {
|
||||||
|
// Calculate offset relative to the parent element (not document.body)
|
||||||
|
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
|
||||||
|
if (!element) return offset;
|
||||||
|
|
||||||
|
// Create range from start of element to the position
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
range.setEnd(container, offset);
|
||||||
|
|
||||||
|
return range.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTempId() {
|
||||||
|
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorSelection(color) {
|
||||||
|
if (!currentRange || !currentSelection) return;
|
||||||
|
|
||||||
|
// Generate XPath-like selectors for start and end containers
|
||||||
|
const startSelector = getXPathSelector(currentRange.startContainer);
|
||||||
|
const endSelector = getXPathSelector(currentRange.endContainer);
|
||||||
|
|
||||||
|
// Calculate offsets relative to the element (not document.body)
|
||||||
|
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
|
||||||
|
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
|
||||||
|
|
||||||
|
// Create annotation element
|
||||||
|
const annotation = document.createElement('rd-annotation');
|
||||||
|
annotation.setAttribute('data-annotation-color', color);
|
||||||
|
annotation.setAttribute('data-annotation-id-value', generateTempId());
|
||||||
|
|
||||||
|
// Wrap selection in annotation
|
||||||
|
try {
|
||||||
|
currentRange.surroundContents(annotation);
|
||||||
|
} catch (e) {
|
||||||
|
// If surroundContents fails (e.g., partial element selection), extract and wrap
|
||||||
|
const fragment = currentRange.extractContents();
|
||||||
|
annotation.appendChild(fragment);
|
||||||
|
currentRange.insertNode(annotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For NativeWebView: use global variable for polling
|
||||||
|
window.__pendingAnnotation = {
|
||||||
|
color: color,
|
||||||
|
text: currentSelection,
|
||||||
|
startOffset: startOffset,
|
||||||
|
endOffset: endOffset,
|
||||||
|
startSelector: startSelector,
|
||||||
|
endSelector: endSelector
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear selection and hide overlay
|
||||||
|
window.getSelection().removeAllRanges();
|
||||||
|
hideOverlay();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateScrollToAnnotationJS() -> String {
|
||||||
|
guard let selectedId = selectedAnnotationId else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
// Scroll to selected annotation and add selected class
|
||||||
|
function scrollToAnnotation() {
|
||||||
|
// Remove 'selected' class from all annotations
|
||||||
|
document.querySelectorAll('rd-annotation.selected').forEach(el => {
|
||||||
|
el.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and highlight selected annotation
|
||||||
|
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
||||||
|
if (selectedElement) {
|
||||||
|
selectedElement.classList.add('selected');
|
||||||
|
|
||||||
|
// Get the element's position relative to the document
|
||||||
|
const rect = selectedElement.getBoundingClientRect();
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||||
|
const elementTop = rect.top + scrollTop;
|
||||||
|
|
||||||
|
// Send position to Swift via polling mechanism
|
||||||
|
setTimeout(() => {
|
||||||
|
window.__pendingScrollPosition = elementTop;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
|
||||||
|
} else {
|
||||||
|
setTimeout(scrollToAnnotation, 300);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hybrid WebView (Not Currently Used)
|
// MARK: - Hybrid WebView (Not Currently Used)
|
||||||
|
|||||||
308
readeck/UI/Components/SettingsRow.swift
Normal file
308
readeck/UI/Components/SettingsRow.swift
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
//
|
||||||
|
// SettingsRow.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 31.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Settings Row with Navigation Link
|
||||||
|
struct SettingsRowNavigationLink<Destination: View>: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let destination: Destination
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
@ViewBuilder destination: () -> Destination
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.destination = destination()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink(destination: destination) {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row with Toggle
|
||||||
|
struct SettingsRowToggle: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
@Binding var isOn: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
isOn: Binding<Bool>
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self._isOn = isOn
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle
|
||||||
|
)
|
||||||
|
Toggle("", isOn: $isOn)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row with Value Display
|
||||||
|
struct SettingsRowValue: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let valueColor: Color
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
valueColor: Color = .secondary
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.value = value
|
||||||
|
self.valueColor = valueColor
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: nil
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.foregroundColor(valueColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row Button (for actions)
|
||||||
|
struct SettingsRowButton: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let destructive: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
destructive: Bool = false,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.destructive = destructive
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: destructive ? .red : iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
titleColor: destructive ? .red : .primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row with Picker
|
||||||
|
struct SettingsRowPicker<T: Hashable>: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let selection: Binding<T>
|
||||||
|
let options: [(value: T, label: String)]
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
selection: Binding<T>,
|
||||||
|
options: [(value: T, label: String)]
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.selection = selection
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: nil
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: selection) {
|
||||||
|
ForEach(options, id: \.value) { option in
|
||||||
|
Text(option.label).tag(option.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row Label (internal component)
|
||||||
|
struct SettingsRowLabel: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let titleColor: Color
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String?,
|
||||||
|
iconColor: Color,
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
titleColor: Color = .primary
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.titleColor = titleColor
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundColor(iconColor)
|
||||||
|
.frame(width: 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.foregroundColor(titleColor)
|
||||||
|
|
||||||
|
if let subtitle = subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
#Preview("Navigation Link") {
|
||||||
|
List {
|
||||||
|
SettingsRowNavigationLink(
|
||||||
|
icon: "paintbrush",
|
||||||
|
title: "App Icon",
|
||||||
|
subtitle: nil
|
||||||
|
) {
|
||||||
|
Text("Detail View")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Toggle") {
|
||||||
|
List {
|
||||||
|
SettingsRowToggle(
|
||||||
|
icon: "speaker.wave.2",
|
||||||
|
title: "Read Aloud Feature",
|
||||||
|
subtitle: "Text-to-Speech functionality",
|
||||||
|
isOn: .constant(true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Value Display") {
|
||||||
|
List {
|
||||||
|
SettingsRowValue(
|
||||||
|
icon: "paintbrush.fill",
|
||||||
|
iconColor: .purple,
|
||||||
|
title: "Tint Color",
|
||||||
|
value: "Purple"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Button") {
|
||||||
|
List {
|
||||||
|
SettingsRowButton(
|
||||||
|
icon: "trash",
|
||||||
|
iconColor: .red,
|
||||||
|
title: "Clear Cache",
|
||||||
|
subtitle: "Remove all cached images",
|
||||||
|
destructive: true
|
||||||
|
) {
|
||||||
|
print("Clear cache tapped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Picker") {
|
||||||
|
List {
|
||||||
|
SettingsRowPicker(
|
||||||
|
icon: "textformat",
|
||||||
|
title: "Font Family",
|
||||||
|
selection: .constant("System"),
|
||||||
|
options: [
|
||||||
|
("System", "System"),
|
||||||
|
("Serif", "Serif"),
|
||||||
|
("Monospace", "Monospace")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
@ -6,6 +6,9 @@ struct WebView: UIViewRepresentable {
|
|||||||
let settings: Settings
|
let settings: Settings
|
||||||
let onHeightChange: (CGFloat) -> Void
|
let onHeightChange: (CGFloat) -> Void
|
||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
|
var selectedAnnotationId: String?
|
||||||
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||||
|
var onScrollToPosition: ((CGFloat) -> Void)? = nil
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
@ -28,8 +31,13 @@ struct WebView: UIViewRepresentable {
|
|||||||
|
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
||||||
|
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
|
||||||
|
webView.configuration.userContentController.add(context.coordinator, name: "scrollToPosition")
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||||
|
context.coordinator.onScrollToPosition = onScrollToPosition
|
||||||
|
context.coordinator.webView = webView
|
||||||
|
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
@ -37,6 +45,8 @@ struct WebView: UIViewRepresentable {
|
|||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||||
|
context.coordinator.onScrollToPosition = onScrollToPosition
|
||||||
|
|
||||||
let isDarkMode = colorScheme == .dark
|
let isDarkMode = colorScheme == .dark
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||||
@ -235,6 +245,49 @@ struct WebView: UIViewRepresentable {
|
|||||||
--separator-color: #e0e0e0;
|
--separator-color: #e0e0e0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Annotation Highlighting - for rd-annotation tags */
|
||||||
|
rd-annotation {
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 0;
|
||||||
|
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Yellow annotations */
|
||||||
|
rd-annotation[data-annotation-color="yellow"] {
|
||||||
|
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
|
||||||
|
}
|
||||||
|
rd-annotation[data-annotation-color="yellow"].selected {
|
||||||
|
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
|
||||||
|
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green annotations */
|
||||||
|
rd-annotation[data-annotation-color="green"] {
|
||||||
|
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
|
||||||
|
}
|
||||||
|
rd-annotation[data-annotation-color="green"].selected {
|
||||||
|
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
|
||||||
|
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blue annotations */
|
||||||
|
rd-annotation[data-annotation-color="blue"] {
|
||||||
|
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
|
||||||
|
}
|
||||||
|
rd-annotation[data-annotation-color="blue"].selected {
|
||||||
|
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
|
||||||
|
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red annotations */
|
||||||
|
rd-annotation[data-annotation-color="red"] {
|
||||||
|
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
|
||||||
|
}
|
||||||
|
rd-annotation[data-annotation-color="red"].selected {
|
||||||
|
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
|
||||||
|
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -264,6 +317,12 @@ struct WebView: UIViewRepresentable {
|
|||||||
document.querySelectorAll('img').forEach(img => {
|
document.querySelectorAll('img').forEach(img => {
|
||||||
img.addEventListener('load', debouncedHeightUpdate);
|
img.addEventListener('load', debouncedHeightUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scroll to selected annotation
|
||||||
|
\(generateScrollToAnnotationJS())
|
||||||
|
|
||||||
|
// Text Selection and Annotation Overlay
|
||||||
|
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -276,6 +335,8 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.navigationDelegate = nil
|
webView.navigationDelegate = nil
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
||||||
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
|
||||||
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition")
|
||||||
webView.loadHTMLString("", baseURL: nil)
|
webView.loadHTMLString("", baseURL: nil)
|
||||||
coordinator.cleanup()
|
coordinator.cleanup()
|
||||||
}
|
}
|
||||||
@ -305,12 +366,303 @@ struct WebView: UIViewRepresentable {
|
|||||||
return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace"
|
return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func generateScrollToAnnotationJS() -> String {
|
||||||
|
guard let selectedId = selectedAnnotationId else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
// Scroll to selected annotation and add selected class
|
||||||
|
function scrollToAnnotation() {
|
||||||
|
// Remove 'selected' class from all annotations
|
||||||
|
document.querySelectorAll('rd-annotation.selected').forEach(el => {
|
||||||
|
el.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and highlight selected annotation
|
||||||
|
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
||||||
|
if (selectedElement) {
|
||||||
|
selectedElement.classList.add('selected');
|
||||||
|
|
||||||
|
// Get the element's position relative to the document
|
||||||
|
const rect = selectedElement.getBoundingClientRect();
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||||
|
const elementTop = rect.top + scrollTop;
|
||||||
|
|
||||||
|
// Send position to Swift
|
||||||
|
setTimeout(() => {
|
||||||
|
window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
|
||||||
|
} else {
|
||||||
|
setTimeout(scrollToAnnotation, 300);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
|
||||||
|
let yellowColor = AnnotationColor.yellow.cssColor(isDark: isDarkMode)
|
||||||
|
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
|
||||||
|
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
|
||||||
|
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
|
||||||
|
|
||||||
|
return """
|
||||||
|
// Create annotation color overlay
|
||||||
|
(function() {
|
||||||
|
let currentSelection = null;
|
||||||
|
let currentRange = null;
|
||||||
|
let selectionTimeout = null;
|
||||||
|
|
||||||
|
// Create overlay container with arrow
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'annotation-overlay';
|
||||||
|
overlay.style.cssText = `
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create arrow/triangle pointing up with glass effect
|
||||||
|
const arrow = document.createElement('div');
|
||||||
|
arrow.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
top: -11px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
`;
|
||||||
|
overlay.appendChild(arrow);
|
||||||
|
|
||||||
|
// Create the actual content container with glass morphism effect
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.15),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||||
|
gap: 12px;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
overlay.appendChild(content);
|
||||||
|
|
||||||
|
// Add "Markierung" label
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = 'Markierung';
|
||||||
|
label.style.cssText = `
|
||||||
|
color: black;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 4px;
|
||||||
|
`;
|
||||||
|
content.appendChild(label);
|
||||||
|
|
||||||
|
// Create color buttons with solid colors
|
||||||
|
const colors = [
|
||||||
|
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
|
||||||
|
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
|
||||||
|
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
|
||||||
|
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
colors.forEach(({ name, color }) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.dataset.color = name;
|
||||||
|
btn.style.cssText = `
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${color};
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
`;
|
||||||
|
btn.addEventListener('mouseenter', () => {
|
||||||
|
btn.style.transform = 'scale(1.1)';
|
||||||
|
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
|
||||||
|
});
|
||||||
|
btn.addEventListener('mouseleave', () => {
|
||||||
|
btn.style.transform = 'scale(1)';
|
||||||
|
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', () => handleColorSelection(name));
|
||||||
|
content.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Selection change listener
|
||||||
|
document.addEventListener('selectionchange', () => {
|
||||||
|
clearTimeout(selectionTimeout);
|
||||||
|
selectionTimeout = setTimeout(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const text = selection.toString().trim();
|
||||||
|
|
||||||
|
if (text.length > 0) {
|
||||||
|
currentSelection = text;
|
||||||
|
currentRange = selection.getRangeAt(0).cloneRange();
|
||||||
|
showOverlay(selection.getRangeAt(0));
|
||||||
|
} else {
|
||||||
|
hideOverlay();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
function showOverlay(range) {
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
const scrollY = window.scrollY || window.pageYOffset;
|
||||||
|
|
||||||
|
overlay.style.display = 'block';
|
||||||
|
|
||||||
|
// Center horizontally under selection
|
||||||
|
const overlayWidth = 320; // Approximate width with label + 4 buttons
|
||||||
|
const centerX = rect.left + (rect.width / 2);
|
||||||
|
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
|
||||||
|
|
||||||
|
// Position with extra space below selection (55px instead of 70px) to bring it closer
|
||||||
|
const topPos = rect.bottom + scrollY + 55;
|
||||||
|
|
||||||
|
overlay.style.left = leftPos + 'px';
|
||||||
|
overlay.style.top = topPos + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideOverlay() {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
currentSelection = null;
|
||||||
|
currentRange = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOffset(container, offset) {
|
||||||
|
const preRange = document.createRange();
|
||||||
|
preRange.selectNodeContents(document.body);
|
||||||
|
preRange.setEnd(container, offset);
|
||||||
|
return preRange.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXPathSelector(node) {
|
||||||
|
// If node is text node, use parent element
|
||||||
|
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
||||||
|
if (!element || element === document.body) return 'body';
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
let current = element;
|
||||||
|
|
||||||
|
while (current && current !== document.body) {
|
||||||
|
const tagName = current.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Count position among siblings of same tag (1-based index)
|
||||||
|
let index = 1;
|
||||||
|
let sibling = current.previousElementSibling;
|
||||||
|
while (sibling) {
|
||||||
|
if (sibling.tagName === current.tagName) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
sibling = sibling.previousElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: tagname[index] (1-based)
|
||||||
|
path.unshift(tagName + '[' + index + ']');
|
||||||
|
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = path.join('/');
|
||||||
|
console.log('Generated selector:', selector);
|
||||||
|
return selector || 'body';
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOffsetInElement(container, offset) {
|
||||||
|
// Calculate offset relative to the parent element (not document.body)
|
||||||
|
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
|
||||||
|
if (!element) return offset;
|
||||||
|
|
||||||
|
// Create range from start of element to the position
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
range.setEnd(container, offset);
|
||||||
|
|
||||||
|
return range.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTempId() {
|
||||||
|
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorSelection(color) {
|
||||||
|
if (!currentRange || !currentSelection) return;
|
||||||
|
|
||||||
|
// Generate XPath-like selectors for start and end containers
|
||||||
|
const startSelector = getXPathSelector(currentRange.startContainer);
|
||||||
|
const endSelector = getXPathSelector(currentRange.endContainer);
|
||||||
|
|
||||||
|
// Calculate offsets relative to the element (not document.body)
|
||||||
|
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
|
||||||
|
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
|
||||||
|
|
||||||
|
// Create annotation element
|
||||||
|
const annotation = document.createElement('rd-annotation');
|
||||||
|
annotation.setAttribute('data-annotation-color', color);
|
||||||
|
annotation.setAttribute('data-annotation-id-value', generateTempId());
|
||||||
|
|
||||||
|
// Wrap selection in annotation
|
||||||
|
try {
|
||||||
|
currentRange.surroundContents(annotation);
|
||||||
|
} catch (e) {
|
||||||
|
// If surroundContents fails (e.g., partial element selection), extract and wrap
|
||||||
|
const fragment = currentRange.extractContents();
|
||||||
|
annotation.appendChild(fragment);
|
||||||
|
currentRange.insertNode(annotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to Swift with selectors
|
||||||
|
window.webkit.messageHandlers.annotationCreated.postMessage({
|
||||||
|
color: color,
|
||||||
|
text: currentSelection,
|
||||||
|
startOffset: startOffset,
|
||||||
|
endOffset: endOffset,
|
||||||
|
startSelector: startSelector,
|
||||||
|
endSelector: endSelector
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear selection and hide overlay
|
||||||
|
window.getSelection().removeAllRanges();
|
||||||
|
hideOverlay();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onHeightChange: ((CGFloat) -> Void)?
|
var onHeightChange: ((CGFloat) -> Void)?
|
||||||
var onScroll: ((Double) -> Void)?
|
var onScroll: ((Double) -> Void)?
|
||||||
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
|
||||||
|
var onScrollToPosition: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
|
// WebView reference
|
||||||
|
weak var webView: WKWebView?
|
||||||
|
|
||||||
// Height management
|
// Height management
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
@ -352,6 +704,22 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
self.handleScrollProgress(progress: progress)
|
self.handleScrollProgress(progress: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if message.name == "annotationCreated", let body = message.body as? [String: Any],
|
||||||
|
let color = body["color"] as? String,
|
||||||
|
let text = body["text"] as? String,
|
||||||
|
let startOffset = body["startOffset"] as? Int,
|
||||||
|
let endOffset = body["endOffset"] as? Int,
|
||||||
|
let startSelector = body["startSelector"] as? String,
|
||||||
|
let endSelector = body["endSelector"] as? String {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if message.name == "scrollToPosition", let position = message.body as? Double {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onScrollToPosition?(CGFloat(position))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleHeightUpdate(height: CGFloat) {
|
private func handleHeightUpdate(height: CGFloat) {
|
||||||
@ -419,13 +787,15 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
func cleanup() {
|
func cleanup() {
|
||||||
guard !isCleanedUp else { return }
|
guard !isCleanedUp else { return }
|
||||||
isCleanedUp = true
|
isCleanedUp = true
|
||||||
|
|
||||||
scrollEndTimer?.invalidate()
|
scrollEndTimer?.invalidate()
|
||||||
scrollEndTimer = nil
|
scrollEndTimer = nil
|
||||||
heightUpdateTimer?.invalidate()
|
heightUpdateTimer?.invalidate()
|
||||||
heightUpdateTimer = nil
|
heightUpdateTimer = nil
|
||||||
|
|
||||||
onHeightChange = nil
|
onHeightChange = nil
|
||||||
onScroll = nil
|
onScroll = nil
|
||||||
|
onAnnotationCreated = nil
|
||||||
|
onScrollToPosition = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,10 +16,15 @@ protocol UseCaseFactory {
|
|||||||
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
|
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
|
||||||
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
||||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||||
|
func makeCreateLabelUseCase() -> PCreateLabelUseCase
|
||||||
|
func makeSyncTagsUseCase() -> PSyncTagsUseCase
|
||||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||||
|
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
||||||
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
||||||
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -30,9 +35,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
||||||
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
||||||
private let settingsRepository: PSettingsRepository = SettingsRepository()
|
private let settingsRepository: PSettingsRepository = SettingsRepository()
|
||||||
|
private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider)
|
||||||
|
private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient)
|
||||||
|
private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api)
|
||||||
|
|
||||||
static let shared = DefaultUseCaseFactory()
|
static let shared = DefaultUseCaseFactory()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func makeLoginUseCase() -> PLoginUseCase {
|
func makeLoginUseCase() -> PLoginUseCase {
|
||||||
@ -96,7 +104,19 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
let labelsRepository = LabelsRepository(api: api)
|
let labelsRepository = LabelsRepository(api: api)
|
||||||
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeCreateLabelUseCase() -> PCreateLabelUseCase {
|
||||||
|
let api = API(tokenProvider: KeychainTokenProvider())
|
||||||
|
let labelsRepository = LabelsRepository(api: api)
|
||||||
|
return CreateLabelUseCase(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()
|
||||||
}
|
}
|
||||||
@ -112,4 +132,16 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||||
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
|
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase {
|
||||||
|
return CheckServerReachabilityUseCase(repository: serverInfoRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
|
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
|
||||||
|
return DeleteAnnotationUseCase(repository: annotationsRepository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import Foundation
|
|||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
class MockUseCaseFactory: UseCaseFactory {
|
class MockUseCaseFactory: UseCaseFactory {
|
||||||
|
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
|
||||||
|
MockCheckServerReachabilityUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
||||||
MockOfflineBookmarkSyncUseCase()
|
MockOfflineBookmarkSyncUseCase()
|
||||||
}
|
}
|
||||||
@ -72,7 +76,15 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
func makeGetLabelsUseCase() -> any PGetLabelsUseCase {
|
func makeGetLabelsUseCase() -> any PGetLabelsUseCase {
|
||||||
MockGetLabelsUseCase()
|
MockGetLabelsUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeCreateLabelUseCase() -> any PCreateLabelUseCase {
|
||||||
|
MockCreateLabelUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
|
||||||
|
MockSyncTagsUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
|
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
|
||||||
MockAddTextToSpeechQueueUseCase()
|
MockAddTextToSpeechQueueUseCase()
|
||||||
}
|
}
|
||||||
@ -84,6 +96,14 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||||
MockSaveCardLayoutUseCase()
|
MockSaveCardLayoutUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
|
MockGetBookmarkAnnotationsUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
|
||||||
|
MockDeleteAnnotationUseCase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -113,6 +133,18 @@ class MockGetLabelsUseCase: PGetLabelsUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockCreateLabelUseCase: PCreateLabelUseCase {
|
||||||
|
func execute(name: String) async throws {
|
||||||
|
// Mock implementation - does nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@ -224,6 +256,30 @@ class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockCheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
|
||||||
|
func execute() async -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getServerInfo() async throws -> ServerInfo {
|
||||||
|
return ServerInfo(version: "1.0.0", buildDate: nil, userAgent: nil, isReachable: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
|
||||||
|
func execute(bookmarkId: String) async throws -> [Annotation] {
|
||||||
|
return [
|
||||||
|
.init(id: "1", text: "bla", created: "", startOffset: 0, endOffset: 1, startSelector: "", endSelector: "")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase {
|
||||||
|
func execute(bookmarkId: String, annotationId: String) async throws {
|
||||||
|
// Mock implementation - do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Bookmark {
|
extension Bookmark {
|
||||||
static let mock: Bookmark = .init(
|
static let mock: Bookmark = .init(
|
||||||
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
||||||
|
|||||||
@ -11,8 +11,8 @@ class OfflineBookmarksViewModel {
|
|||||||
private let successDelaySubject = PassthroughSubject<Int, Never>()
|
private let successDelaySubject = PassthroughSubject<Int, Never>()
|
||||||
private var completionTimerActive = false
|
private var completionTimerActive = false
|
||||||
|
|
||||||
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
|
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
self.syncUseCase = syncUseCase
|
self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase()
|
||||||
setupBindings()
|
setupBindings()
|
||||||
refreshState()
|
refreshState()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ struct PadSidebarView: View {
|
|||||||
@State private var selectedTag: BookmarkLabel?
|
@State private var selectedTag: BookmarkLabel?
|
||||||
@EnvironmentObject var playerUIState: PlayerUIState
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
|
||||||
|
|
||||||
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
||||||
|
|
||||||
@ -87,11 +87,11 @@ struct PadSidebarView: View {
|
|||||||
case .all:
|
case .all:
|
||||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
case .unread:
|
case .unread:
|
||||||
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
|
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
case .favorite:
|
case .favorite:
|
||||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
|
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
case .archived:
|
case .archived:
|
||||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
|
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView()
|
SettingsView()
|
||||||
case .article:
|
case .article:
|
||||||
|
|||||||
@ -12,7 +12,7 @@ struct PhoneTabView: View {
|
|||||||
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
||||||
|
|
||||||
@State private var selectedTab: SidebarTab = .unread
|
@State private var selectedTab: SidebarTab = .unread
|
||||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
|
||||||
|
|
||||||
// Navigation paths for each tab
|
// Navigation paths for each tab
|
||||||
@State private var allPath = NavigationPath()
|
@State private var allPath = NavigationPath()
|
||||||
@ -149,11 +149,11 @@ struct PhoneTabView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
||||||
List(bookmarks) { bookmark in
|
List(bookmarks) { bookmark in
|
||||||
// Hidden NavigationLink to remove disclosure indicator
|
|
||||||
// To restore: uncomment block below and remove ZStack
|
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|
||||||
|
// Hidden NavigationLink to remove disclosure indicator
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||||
} label: {
|
} label: {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
@ -234,11 +234,11 @@ struct PhoneTabView: View {
|
|||||||
case .all:
|
case .all:
|
||||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||||
case .unread:
|
case .unread:
|
||||||
BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil))
|
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||||
case .favorite:
|
case .favorite:
|
||||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
|
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||||
case .archived:
|
case .archived:
|
||||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
|
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||||
case .search:
|
case .search:
|
||||||
EmptyView() // search is directly implemented
|
EmptyView() // search is directly implemented
|
||||||
case .settings:
|
case .settings:
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
175
readeck/UI/Onboarding/OnboardingServerView.swift
Normal file
175
readeck/UI/Onboarding/OnboardingServerView.swift
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
//
|
||||||
|
// OnboardingServerView.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 31.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OnboardingServerView: View {
|
||||||
|
@State private var viewModel = SettingsServerViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
SectionHeader(title: "Server Settings".localized, icon: "server.rack")
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
Text("Enter your Readeck server details to get started.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
// Form
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Server Endpoint
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
TextField("",
|
||||||
|
text: $viewModel.endpoint,
|
||||||
|
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.onChange(of: viewModel.endpoint) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Input Chips
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
QuickInputChip(text: "http://", action: {
|
||||||
|
if !viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint = "http://" + viewModel.endpoint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: "https://", action: {
|
||||||
|
if !viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint = "https://" + viewModel.endpoint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: "192.168.", action: {
|
||||||
|
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
|
||||||
|
if viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint += "192.168."
|
||||||
|
} else {
|
||||||
|
viewModel.endpoint = "http://192.168."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: ":8000", action: {
|
||||||
|
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
|
||||||
|
viewModel.endpoint += ":8000"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
TextField("",
|
||||||
|
text: $viewModel.username,
|
||||||
|
prompt: Text("Username").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.onChange(of: viewModel.username) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
SecureField("",
|
||||||
|
text: $viewModel.password,
|
||||||
|
prompt: Text("Password").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onChange(of: viewModel.password) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text(successMessage)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button(action: {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveServerSettings()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
}
|
||||||
|
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadServerSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Input Chip Component
|
||||||
|
|
||||||
|
struct QuickInputChip: View {
|
||||||
|
let text: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.systemGray5))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingServerView()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@ -4,7 +4,54 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi
|
|||||||
|
|
||||||
**AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features.
|
**AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features.
|
||||||
|
|
||||||
## Version 1.1
|
## Version 1.2.0
|
||||||
|
|
||||||
|
### Annotations & Highlighting
|
||||||
|
|
||||||
|
- **Highlight important passages** directly in your articles
|
||||||
|
- Select text to bring up a beautiful color picker overlay
|
||||||
|
- Choose from four distinct colors: yellow, green, blue, and red
|
||||||
|
- Your highlights are saved and synced across devices
|
||||||
|
- Tap on annotations in the list to jump directly to that passage in the article
|
||||||
|
- Glass morphism design for a modern, elegant look
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
- **Dramatically faster label loading** - especially with 1000+ labels
|
||||||
|
- Labels now load instantly, even without internet connection
|
||||||
|
- Share Extension loads much faster
|
||||||
|
- Better performance when working with many labels
|
||||||
|
- Improved overall app stability
|
||||||
|
|
||||||
|
### Settings Redesign
|
||||||
|
|
||||||
|
- **Completely redesigned settings screen** with native iOS style
|
||||||
|
- Font settings moved to dedicated screen with larger preview
|
||||||
|
- Reorganized sections for better overview
|
||||||
|
- Inline explanations directly under settings
|
||||||
|
- Cleaner app info footer with muted styling
|
||||||
|
- Combined legal, privacy and support into one section
|
||||||
|
|
||||||
|
### Tag Management Improvements
|
||||||
|
|
||||||
|
- **Handles 1000+ tags smoothly** - no more lag or slowdowns
|
||||||
|
- **Tags now load from local database** - no internet required
|
||||||
|
- Choose your preferred tag sorting: by usage count or alphabetically
|
||||||
|
- Tags sync automatically in the background
|
||||||
|
- Share Extension shows your 150 most-used tags instantly
|
||||||
|
- Better offline support for managing tags
|
||||||
|
- Faster and more responsive tag selection
|
||||||
|
|
||||||
|
### Fixes & Improvements
|
||||||
|
|
||||||
|
- Better color consistency throughout the app
|
||||||
|
- Improved text selection in articles
|
||||||
|
- Better formatted release notes
|
||||||
|
- Various bug fixes and stability improvements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version 1.1.0
|
||||||
|
|
||||||
There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development.
|
There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development.
|
||||||
|
|
||||||
@ -3,88 +3,141 @@ 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 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
|
||||||
|
|
||||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
init(
|
||||||
|
factory: UseCaseFactory = DefaultUseCaseFactory.shared,
|
||||||
|
fontViewModel: FontSettingsViewModel = FontSettingsViewModel(),
|
||||||
|
generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()
|
||||||
|
) {
|
||||||
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||||
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
||||||
self.settingsRepository = SettingsRepository()
|
self.settingsRepository = SettingsRepository()
|
||||||
|
self.fontViewModel = fontViewModel
|
||||||
|
self.generalViewModel = generalViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Appearance".localized, icon: "paintbrush")
|
Section {
|
||||||
.padding(.bottom, 4)
|
// Font Settings als NavigationLink
|
||||||
|
NavigationLink {
|
||||||
// Theme Section
|
FontSelectionView(viewModel: fontViewModel)
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
} label: {
|
||||||
Text("Theme")
|
HStack {
|
||||||
.font(.headline)
|
Text("Font")
|
||||||
|
Spacer()
|
||||||
|
Text("\(fontViewModel.selectedFontFamily.displayName) · \(fontViewModel.selectedFontSize.displayName)")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme Picker (Menu statt Segmented)
|
||||||
Picker("Theme", selection: $selectedTheme) {
|
Picker("Theme", selection: $selectedTheme) {
|
||||||
ForEach(Theme.allCases, id: \.self) { theme in
|
ForEach(Theme.allCases, id: \.self) { theme in
|
||||||
Text(theme.displayName).tag(theme)
|
Text(theme.displayName).tag(theme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.onChange(of: selectedTheme) {
|
.onChange(of: selectedTheme) {
|
||||||
saveThemeSettings()
|
saveThemeSettings()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Card Layout als NavigationLink
|
||||||
Divider()
|
NavigationLink {
|
||||||
|
CardLayoutSelectionView(
|
||||||
// Card Layout Section
|
selectedCardLayout: $selectedCardLayout,
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
onSave: saveCardLayoutSettings
|
||||||
Text("Card Layout")
|
)
|
||||||
.font(.headline)
|
} label: {
|
||||||
|
HStack {
|
||||||
VStack(spacing: 16) {
|
Text("Card Layout")
|
||||||
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
Spacer()
|
||||||
CardLayoutPreview(
|
Text(selectedCardLayout.displayName)
|
||||||
layout: layout,
|
.foregroundColor(.secondary)
|
||||||
isSelected: selectedCardLayout == layout
|
|
||||||
) {
|
|
||||||
selectedCardLayout = layout
|
|
||||||
saveCardLayoutSettings()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open external links in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Picker("Open links in", selection: $generalViewModel.urlOpener) {
|
||||||
|
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
|
||||||
|
Text(urlOpener.displayName).tag(urlOpener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: generalViewModel.urlOpener) {
|
||||||
|
Task {
|
||||||
|
await generalViewModel.saveGeneralSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag Sort Order
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Picker("Tag sort order", selection: $selectedTagSortOrder) {
|
||||||
|
ForEach(TagSortOrder.allCases, id: \.self) { sortOrder in
|
||||||
|
Text(sortOrder.displayName).tag(sortOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: selectedTagSortOrder) {
|
||||||
|
saveTagSortOrderSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Determines how tags are displayed when adding or editing bookmarks.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Appearance")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.task {
|
||||||
|
await fontViewModel.loadFontSettings()
|
||||||
|
await generalViewModel.loadGeneralSettings()
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveThemeSettings() {
|
private func saveThemeSettings() {
|
||||||
Task {
|
Task {
|
||||||
// Load current settings, update theme, and save back
|
// Load current settings, update theme, and save back
|
||||||
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
||||||
settings.theme = selectedTheme
|
settings.theme = selectedTheme
|
||||||
try? await settingsRepository.saveSettings(settings)
|
try? await settingsRepository.saveSettings(settings)
|
||||||
|
|
||||||
// Notify app about theme change
|
// Notify app about theme change
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveCardLayoutSettings() {
|
private func saveCardLayoutSettings() {
|
||||||
Task {
|
Task {
|
||||||
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
||||||
@ -94,141 +147,27 @@ struct AppearanceSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private func saveTagSortOrderSettings() {
|
||||||
|
Task {
|
||||||
|
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
||||||
|
settings.tagSortOrder = selectedTagSortOrder
|
||||||
|
try? await settingsRepository.saveSettings(settings)
|
||||||
|
|
||||||
struct CardLayoutPreview: View {
|
// Update AppSettings to trigger UI updates
|
||||||
let layout: CardLayoutStyle
|
await MainActor.run {
|
||||||
let isSelected: Bool
|
appSettings.settings?.tagSortOrder = selectedTagSortOrder
|
||||||
let onSelect: () -> Void
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: onSelect) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
// Visual Preview
|
|
||||||
switch layout {
|
|
||||||
case .compact:
|
|
||||||
// Compact: Small image on left, content on right
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(width: 24, height: 24)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 6)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 60)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.4))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 50)
|
|
||||||
|
|
||||||
case .magazine:
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(height: 24)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 5)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 40)
|
|
||||||
|
|
||||||
Text("Fixed 140px")
|
|
||||||
.font(.system(size: 7))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
.padding(6)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 65)
|
|
||||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
|
||||||
|
|
||||||
case .natural:
|
|
||||||
VStack(spacing: 3) {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(height: 38)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 5)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 35)
|
|
||||||
|
|
||||||
Text("Original ratio")
|
|
||||||
.font(.system(size: 7))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
.padding(6)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 75) // Höher als Magazine
|
|
||||||
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(layout.displayName)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Text(layout.description)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if isSelected {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
.font(.title2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AppearanceSettingsView()
|
NavigationStack {
|
||||||
.cardStyle()
|
List {
|
||||||
.padding()
|
AppearanceSettingsView()
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,79 +6,67 @@ struct CacheSettingsView: View {
|
|||||||
@State private var maxCacheSize: Double = 200
|
@State private var maxCacheSize: Double = 200
|
||||||
@State private var isClearing: Bool = false
|
@State private var isClearing: Bool = false
|
||||||
@State private var showClearAlert: Bool = false
|
@State private var showClearAlert: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Section {
|
||||||
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
|
HStack {
|
||||||
.padding(.bottom, 4)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Current Cache Size")
|
||||||
VStack(spacing: 12) {
|
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
||||||
HStack {
|
.font(.caption)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.foregroundColor(.secondary)
|
||||||
Text("Current Cache Size")
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button("Refresh") {
|
|
||||||
updateCacheSize()
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
Divider()
|
Button("Refresh") {
|
||||||
|
updateCacheSize()
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack {
|
|
||||||
Text("Max Cache Size")
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(maxCacheSize)) MB")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
|
||||||
Text("Max Cache Size")
|
|
||||||
}
|
|
||||||
.onChange(of: maxCacheSize) { _, newValue in
|
|
||||||
updateMaxCacheSize(newValue)
|
|
||||||
}
|
|
||||||
.accentColor(.blue)
|
|
||||||
}
|
}
|
||||||
|
.font(.caption)
|
||||||
Divider()
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showClearAlert = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if isClearing {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
.frame(width: 24)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.frame(width: 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Clear Cache")
|
|
||||||
.foregroundColor(isClearing ? .secondary : .red)
|
|
||||||
Text("Remove all cached images")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(isClearing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(maxCacheSize)) MB")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
}
|
||||||
|
.onChange(of: maxCacheSize) { _, newValue in
|
||||||
|
updateMaxCacheSize(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showClearAlert = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if isClearing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Clear Cache")
|
||||||
|
.foregroundColor(isClearing ? .secondary : .red)
|
||||||
|
Text("Remove all cached images")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isClearing)
|
||||||
|
} header: {
|
||||||
|
Text("Cache Settings")
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateCacheSize()
|
updateCacheSize()
|
||||||
@ -93,7 +81,7 @@ struct CacheSettingsView: View {
|
|||||||
Text("This will remove all cached images. They will be downloaded again when needed.")
|
Text("This will remove all cached images. They will be downloaded again when needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCacheSize() {
|
private func updateCacheSize() {
|
||||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -107,7 +95,7 @@ struct CacheSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadMaxCacheSize() {
|
private func loadMaxCacheSize() {
|
||||||
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
||||||
if let savedSize = savedSize {
|
if let savedSize = savedSize {
|
||||||
@ -120,29 +108,30 @@ struct CacheSettingsView: View {
|
|||||||
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMaxCacheSize(_ newSize: Double) {
|
private func updateMaxCacheSize(_ newSize: Double) {
|
||||||
let bytes = UInt(newSize * 1024 * 1024)
|
let bytes = UInt(newSize * 1024 * 1024)
|
||||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
||||||
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearCache() {
|
private func clearCache() {
|
||||||
isClearing = true
|
isClearing = true
|
||||||
|
|
||||||
KingfisherManager.shared.cache.clearDiskCache {
|
KingfisherManager.shared.cache.clearDiskCache {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isClearing = false
|
self.isClearing = false
|
||||||
self.updateCacheSize()
|
self.updateCacheSize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KingfisherManager.shared.cache.clearMemoryCache()
|
KingfisherManager.shared.cache.clearMemoryCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CacheSettingsView()
|
List {
|
||||||
.cardStyle()
|
CacheSettingsView()
|
||||||
.padding()
|
}
|
||||||
}
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|||||||
171
readeck/UI/Settings/CardLayoutSelectionView.swift
Normal file
171
readeck/UI/Settings/CardLayoutSelectionView.swift
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
//
|
||||||
|
// CardLayoutSelectionView.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 31.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CardLayoutSelectionView: View {
|
||||||
|
@Binding var selectedCardLayout: CardLayoutStyle
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
||||||
|
CardLayoutPreview(
|
||||||
|
layout: layout,
|
||||||
|
isSelected: selectedCardLayout == layout
|
||||||
|
) {
|
||||||
|
selectedCardLayout = layout
|
||||||
|
onSave()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Card Layout")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CardLayoutPreview: View {
|
||||||
|
let layout: CardLayoutStyle
|
||||||
|
let isSelected: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Visual Preview
|
||||||
|
switch layout {
|
||||||
|
case .compact:
|
||||||
|
// Compact: Small image on left, content on right
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 60)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.4))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 50)
|
||||||
|
|
||||||
|
case .magazine:
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 5)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 40)
|
||||||
|
|
||||||
|
Text("Fixed 140px")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 65)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||||
|
|
||||||
|
case .natural:
|
||||||
|
VStack(spacing: 3) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(height: 38)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 5)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 35)
|
||||||
|
|
||||||
|
Text("Original ratio")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 75)
|
||||||
|
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(layout.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(layout.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
CardLayoutSelectionView(
|
||||||
|
selectedCardLayout: .constant(.magazine),
|
||||||
|
onSave: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
444
readeck/UI/Settings/DebugLogViewer.swift
Normal file
444
readeck/UI/Settings/DebugLogViewer.swift
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
//
|
||||||
|
// DebugLogViewer.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DebugLogViewer: View {
|
||||||
|
@State private var entries: [LogEntry] = []
|
||||||
|
@State private var selectedLevel: LogLevel?
|
||||||
|
@State private var selectedCategory: LogCategory?
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var showShareSheet = false
|
||||||
|
@State private var exportText = ""
|
||||||
|
@State private var autoScroll = true
|
||||||
|
@State private var showFilters = false
|
||||||
|
@StateObject private var logConfig = LogConfiguration.shared
|
||||||
|
|
||||||
|
private let logger = Logger.ui
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Logging Disabled Warning
|
||||||
|
if !logConfig.isLoggingEnabled {
|
||||||
|
loggingDisabledBanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Bar
|
||||||
|
if showFilters {
|
||||||
|
filterBar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log List
|
||||||
|
if filteredEntries.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
List {
|
||||||
|
ForEach(filteredEntries) { entry in
|
||||||
|
LogEntryRow(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.onChange(of: entries.count) { oldValue, newValue in
|
||||||
|
if autoScroll, let lastEntry = filteredEntries.last {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(lastEntry.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Debug Logs")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
showFilters.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
showFilters ? "Hide Filters" : "Show Filters",
|
||||||
|
systemImage: "line.3.horizontal.decrease.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
autoScroll.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll",
|
||||||
|
systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await refreshLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await exportLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Export Logs", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await clearLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Clear All Logs", systemImage: "trash")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: "Search logs")
|
||||||
|
.task {
|
||||||
|
await refreshLogs()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showShareSheet) {
|
||||||
|
ActivityView(activityItems: [exportText])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var filterBar: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Filters")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Level Filter
|
||||||
|
Menu {
|
||||||
|
Button("All Levels") {
|
||||||
|
selectedLevel = nil
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
selectedLevel = level
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
if selectedLevel == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level")
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
||||||
|
.foregroundColor(selectedLevel != nil ? .accentColor : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category Filter
|
||||||
|
Menu {
|
||||||
|
Button("All Categories") {
|
||||||
|
selectedCategory = nil
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||||
|
Button {
|
||||||
|
selectedCategory = category
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(category.rawValue)
|
||||||
|
if selectedCategory == category {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
Text(selectedCategory?.rawValue ?? "Category")
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
||||||
|
.foregroundColor(selectedCategory != nil ? .accentColor : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Filters
|
||||||
|
if selectedLevel != nil || selectedCategory != nil {
|
||||||
|
Button {
|
||||||
|
selectedLevel = nil
|
||||||
|
selectedCategory = nil
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
Text("Clear")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.systemGray5))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loggingDisabledBanner: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.title3)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Logging Disabled")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text("Enable logging in settings to capture new logs")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
logConfig.isLoggingEnabled = true
|
||||||
|
} label: {
|
||||||
|
Text("Enable")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.orange)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "doc.text.magnifyingglass")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("No Logs Found")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil {
|
||||||
|
Text("Try adjusting your filters or search criteria")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
searchText = ""
|
||||||
|
selectedLevel = nil
|
||||||
|
selectedCategory = nil
|
||||||
|
} label: {
|
||||||
|
Text("Clear Filters")
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Logs will appear here as they are generated")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredEntries: [LogEntry] {
|
||||||
|
var filtered = entries
|
||||||
|
|
||||||
|
if let level = selectedLevel {
|
||||||
|
filtered = filtered.filter { $0.level == level }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let category = selectedCategory {
|
||||||
|
filtered = filtered.filter { $0.category == category }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
filtered = filtered.filter {
|
||||||
|
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshLogs() async {
|
||||||
|
entries = await LogStore.shared.getEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearLogs() async {
|
||||||
|
await LogStore.shared.clear()
|
||||||
|
await refreshLogs()
|
||||||
|
logger.info("Cleared all debug logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportLogs() async {
|
||||||
|
exportText = await LogStore.shared.exportAsText()
|
||||||
|
showShareSheet = true
|
||||||
|
logger.info("Exported debug logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log Entry Row
|
||||||
|
|
||||||
|
struct LogEntryRow: View {
|
||||||
|
let entry: LogEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
// Level Badge
|
||||||
|
Text(levelName(for: entry.level))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(levelColor(for: entry.level).opacity(0.2))
|
||||||
|
.foregroundColor(levelColor(for: entry.level))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
// Category
|
||||||
|
Text(entry.category.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
Text(entry.formattedTimestamp)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message
|
||||||
|
Text(entry.message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
// Source Location
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(entry.fileName):\(entry.line)")
|
||||||
|
.font(.caption)
|
||||||
|
Text("•")
|
||||||
|
.font(.caption)
|
||||||
|
Text(entry.function)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "DEBUG"
|
||||||
|
case 1: return "INFO"
|
||||||
|
case 2: return "NOTICE"
|
||||||
|
case 3: return "WARN"
|
||||||
|
case 4: return "ERROR"
|
||||||
|
case 5: return "CRITICAL"
|
||||||
|
default: return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelColor(for level: LogLevel) -> Color {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return .blue
|
||||||
|
case 1: return .green
|
||||||
|
case 2: return .cyan
|
||||||
|
case 3: return .orange
|
||||||
|
case 4: return .red
|
||||||
|
case 5: return .purple
|
||||||
|
default: return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Activity View (for Share Sheet)
|
||||||
|
|
||||||
|
struct ActivityView: UIViewControllerRepresentable {
|
||||||
|
let activityItems: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
DebugLogViewer()
|
||||||
|
}
|
||||||
|
}
|
||||||
105
readeck/UI/Settings/FontSelectionView.swift
Normal file
105
readeck/UI/Settings/FontSelectionView.swift
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
//
|
||||||
|
// FontSelectionView.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 08.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FontSelectionView: View {
|
||||||
|
@State private var viewModel: FontSettingsViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
// Preview Section
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("readeck Bookmark Title")
|
||||||
|
.font(viewModel.previewTitleFont)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
|
||||||
|
.font(viewModel.previewBodyFont)
|
||||||
|
.lineLimit(4)
|
||||||
|
|
||||||
|
Text("12 min • Today • example.com")
|
||||||
|
.font(viewModel.previewCaptionFont)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(16)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: Color.black.opacity(0.08), radius: 8, x: 0, y: 2)
|
||||||
|
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
} header: {
|
||||||
|
Text("Preview")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font Settings Section
|
||||||
|
Section {
|
||||||
|
Picker("Font family", selection: $viewModel.selectedFontFamily) {
|
||||||
|
ForEach(FontFamily.allCases, id: \.self) { family in
|
||||||
|
Text(family.displayName).tag(family)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.selectedFontFamily) {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Font size")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
||||||
|
ForEach(FontSize.allCases, id: \.self) { size in
|
||||||
|
Text(size.displayName).tag(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.selectedFontSize) {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} header: {
|
||||||
|
Text("Font Settings")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.navigationTitle("Font")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
await viewModel.loadFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
FontSelectionView(viewModel: .init(
|
||||||
|
factory: MockUseCaseFactory()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,82 +9,60 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FontSettingsView: View {
|
struct FontSettingsView: View {
|
||||||
@State private var viewModel: FontSettingsViewModel
|
@State private var viewModel: FontSettingsViewModel
|
||||||
|
|
||||||
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
|
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Font Settings".localized, icon: "textformat")
|
Section {
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
// Font Family Picker
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
|
||||||
Text("Font family")
|
|
||||||
.font(.headline)
|
|
||||||
Picker("Font family", selection: $viewModel.selectedFontFamily) {
|
Picker("Font family", selection: $viewModel.selectedFontFamily) {
|
||||||
ForEach(FontFamily.allCases, id: \.self) { family in
|
ForEach(FontFamily.allCases, id: \.self) { family in
|
||||||
Text(family.displayName).tag(family)
|
Text(family.displayName).tag(family)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(MenuPickerStyle())
|
|
||||||
.onChange(of: viewModel.selectedFontFamily) {
|
.onChange(of: viewModel.selectedFontFamily) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.saveFontSettings()
|
await viewModel.saveFontSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
||||||
|
ForEach(FontSize.allCases, id: \.self) { size in
|
||||||
|
Text(size.displayName).tag(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.selectedFontSize) {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Font Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
// Font Size Picker
|
Text("readeck Bookmark Title")
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.font(viewModel.previewTitleFont)
|
||||||
Text("Font size")
|
.fontWeight(.semibold)
|
||||||
.font(.headline)
|
.lineLimit(1)
|
||||||
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
|
||||||
ForEach(FontSize.allCases, id: \.self) { size in
|
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
||||||
Text(size.displayName).tag(size)
|
.font(viewModel.previewBodyFont)
|
||||||
}
|
.lineLimit(3)
|
||||||
}
|
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
Text("12 min • Today • example.com")
|
||||||
.onChange(of: viewModel.selectedFontSize) {
|
.font(viewModel.previewCaptionFont)
|
||||||
Task {
|
|
||||||
await viewModel.saveFontSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font Preview
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Preview")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("readeck Bookmark Title")
|
|
||||||
.font(viewModel.previewTitleFont)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
|
||||||
.font(viewModel.previewBodyFont)
|
|
||||||
.lineLimit(3)
|
|
||||||
|
|
||||||
Text("12 min • Today • example.com")
|
|
||||||
.font(viewModel.previewCaptionFont)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(4)
|
|
||||||
.background(Color(.systemGray6))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} header: {
|
||||||
|
Text("Preview")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
|
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadFontSettings()
|
await viewModel.loadFontSettings()
|
||||||
}
|
}
|
||||||
@ -92,7 +70,10 @@ struct FontSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
FontSettingsView(viewModel: .init(
|
List {
|
||||||
factory: MockUseCaseFactory())
|
FontSettingsView(viewModel: .init(
|
||||||
)
|
factory: MockUseCaseFactory())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,110 +3,79 @@ import SwiftUI
|
|||||||
struct LegalPrivacySettingsView: View {
|
struct LegalPrivacySettingsView: View {
|
||||||
@State private var showingPrivacyPolicy = false
|
@State private var showingPrivacyPolicy = false
|
||||||
@State private var showingLegalNotice = false
|
@State private var showingLegalNotice = false
|
||||||
|
@State private var showReleaseNotes = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
|
Section {
|
||||||
.padding(.bottom, 4)
|
Button(action: {
|
||||||
|
showReleaseNotes = true
|
||||||
VStack(spacing: 16) {
|
}) {
|
||||||
// Privacy Policy
|
HStack {
|
||||||
|
Text("What's New")
|
||||||
|
Spacer()
|
||||||
|
Text("Version \(VersionManager.shared.currentVersion)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingPrivacyPolicy = true
|
showingPrivacyPolicy = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(NSLocalizedString("Privacy Policy", comment: ""))
|
Text(NSLocalizedString("Privacy Policy", comment: ""))
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
// Legal Notice
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingLegalNotice = true
|
showingLegalNotice = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(NSLocalizedString("Legal Notice", comment: ""))
|
Text(NSLocalizedString("Legal Notice", comment: ""))
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
Button(action: {
|
||||||
Divider()
|
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
||||||
.padding(.vertical, 8)
|
UIApplication.shared.open(url)
|
||||||
|
|
||||||
// Support Section
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
// Report an Issue
|
|
||||||
Button(action: {
|
|
||||||
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Report an Issue", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
}) {
|
||||||
|
HStack {
|
||||||
// Contact Support
|
Text(NSLocalizedString("Report an Issue", comment: ""))
|
||||||
Button(action: {
|
Spacer()
|
||||||
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
Image(systemName: "arrow.up.right")
|
||||||
UIApplication.shared.open(url)
|
.font(.caption)
|
||||||
}
|
.foregroundColor(.secondary)
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Contact Support", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text(NSLocalizedString("Contact Support", comment: ""))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Legal, Privacy & Support")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingPrivacyPolicy) {
|
.sheet(isPresented: $showingPrivacyPolicy) {
|
||||||
@ -115,11 +84,15 @@ struct LegalPrivacySettingsView: View {
|
|||||||
.sheet(isPresented: $showingLegalNotice) {
|
.sheet(isPresented: $showingLegalNotice) {
|
||||||
LegalNoticeView()
|
LegalNoticeView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showReleaseNotes) {
|
||||||
|
ReleaseNotesView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
LegalPrivacySettingsView()
|
List {
|
||||||
.cardStyle()
|
LegalPrivacySettingsView()
|
||||||
.padding()
|
}
|
||||||
}
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|||||||
@ -5,97 +5,83 @@
|
|||||||
// Created by Ilyas Hallak on 16.08.25.
|
// Created by Ilyas Hallak on 16.08.25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import os
|
import os
|
||||||
|
|
||||||
struct LoggingConfigurationView: View {
|
struct LoggingConfigurationView: View {
|
||||||
@StateObject private var logConfig = LogConfiguration.shared
|
@StateObject private var logConfig = LogConfiguration.shared
|
||||||
private let logger = Logger.ui
|
private let logger = Logger.ui
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
List {
|
||||||
Form {
|
Section {
|
||||||
Section(header: Text("Global Settings")) {
|
Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.tint(.green)
|
||||||
Text("Global Minimum Level")
|
} header: {
|
||||||
.font(.headline)
|
Text("Logging Status")
|
||||||
|
} footer: {
|
||||||
Picker("Global Level", selection: $logConfig.globalMinLevel) {
|
Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.")
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
}
|
||||||
HStack {
|
|
||||||
Text(level.emoji)
|
if logConfig.isLoggingEnabled {
|
||||||
Text(level.rawValue == 0 ? "Debug" :
|
Section {
|
||||||
level.rawValue == 1 ? "Info" :
|
NavigationLink {
|
||||||
level.rawValue == 2 ? "Notice" :
|
GlobalLogLevelView(logConfig: logConfig)
|
||||||
level.rawValue == 3 ? "Warning" :
|
} label: {
|
||||||
level.rawValue == 4 ? "Error" : "Critical")
|
HStack {
|
||||||
}
|
Label("Global Log Level", systemImage: "slider.horizontal.3")
|
||||||
.tag(level)
|
Spacer()
|
||||||
}
|
Text(levelName(for: logConfig.globalMinLevel))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
|
||||||
|
|
||||||
Text("Logs below this level will be filtered out globally")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
|
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
|
||||||
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
|
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
|
||||||
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
|
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
|
||||||
}
|
} header: {
|
||||||
|
Text("Global Settings")
|
||||||
Section(header: Text("Category-specific Levels")) {
|
} footer: {
|
||||||
ForEach(LogCategory.allCases, id: \.self) { category in
|
Text("Logs below the global level will be filtered out globally")
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
}
|
||||||
HStack {
|
}
|
||||||
Text(category.rawValue)
|
|
||||||
.font(.headline)
|
if logConfig.isLoggingEnabled {
|
||||||
Spacer()
|
Section {
|
||||||
Text(levelName(for: logConfig.getLevel(for: category)))
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||||
.font(.caption)
|
NavigationLink {
|
||||||
.foregroundColor(.secondary)
|
CategoryLogLevelView(category: category, logConfig: logConfig)
|
||||||
}
|
} label: {
|
||||||
|
HStack {
|
||||||
Picker("Level for \(category.rawValue)", selection: Binding(
|
Text(category.rawValue)
|
||||||
get: { logConfig.getLevel(for: category) },
|
Spacer()
|
||||||
set: { logConfig.setLevel($0, for: category) }
|
Text(levelName(for: logConfig.getLevel(for: category)))
|
||||||
)) {
|
.foregroundColor(.secondary)
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
}
|
||||||
HStack {
|
}
|
||||||
Text(level.emoji)
|
}
|
||||||
Text(levelName(for: level))
|
} header: {
|
||||||
}
|
Text("Category-specific Levels")
|
||||||
.tag(level)
|
} footer: {
|
||||||
}
|
Text("Configure log levels for each category individually")
|
||||||
}
|
}
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
}
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
Section {
|
||||||
}
|
Button(role: .destructive) {
|
||||||
}
|
resetToDefaults()
|
||||||
|
} label: {
|
||||||
Section(header: Text("Reset")) {
|
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||||
Button("Reset to Defaults") {
|
|
||||||
resetToDefaults()
|
|
||||||
}
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Logging Configuration")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Logging Configuration")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
logger.debug("Opened logging configuration view")
|
logger.debug("Opened logging configuration view")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func levelName(for level: LogLevel) -> String {
|
private func levelName(for level: LogLevel) -> String {
|
||||||
switch level.rawValue {
|
switch level.rawValue {
|
||||||
case 0: return "Debug"
|
case 0: return "Debug"
|
||||||
@ -107,25 +93,140 @@ struct LoggingConfigurationView: View {
|
|||||||
default: return "Unknown"
|
default: return "Unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetToDefaults() {
|
private func resetToDefaults() {
|
||||||
logger.info("Resetting logging configuration to defaults")
|
logger.info("Resetting logging configuration to defaults")
|
||||||
|
|
||||||
// Reset all category levels (this will use globalMinLevel as fallback)
|
|
||||||
for category in LogCategory.allCases {
|
for category in LogCategory.allCases {
|
||||||
logConfig.setLevel(.debug, for: category)
|
logConfig.setLevel(.debug, for: category)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset global settings
|
|
||||||
logConfig.globalMinLevel = .debug
|
logConfig.globalMinLevel = .debug
|
||||||
logConfig.showPerformanceLogs = true
|
logConfig.showPerformanceLogs = true
|
||||||
logConfig.showTimestamps = true
|
logConfig.showTimestamps = true
|
||||||
logConfig.includeSourceLocation = true
|
logConfig.includeSourceLocation = true
|
||||||
|
|
||||||
logger.info("Logging configuration reset to defaults")
|
logger.info("Logging configuration reset to defaults")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
// MARK: - Global Log Level View
|
||||||
LoggingConfigurationView()
|
|
||||||
|
struct GlobalLogLevelView: View {
|
||||||
|
@ObservedObject var logConfig: LogConfiguration
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
logConfig.globalMinLevel = level
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(levelDescription(for: level))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if logConfig.globalMinLevel == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Global Log Level")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelDescription(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Show all logs including debug information"
|
||||||
|
case 1: return "Show informational messages and above"
|
||||||
|
case 2: return "Show notable events and above"
|
||||||
|
case 3: return "Show warnings and errors only"
|
||||||
|
case 4: return "Show errors and critical issues only"
|
||||||
|
case 5: return "Show only critical issues"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Log Level View
|
||||||
|
|
||||||
|
struct CategoryLogLevelView: View {
|
||||||
|
let category: LogCategory
|
||||||
|
@ObservedObject var logConfig: LogConfiguration
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
logConfig.setLevel(level, for: category)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(levelDescription(for: level))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if logConfig.getLevel(for: category) == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("\(category.rawValue) Logs")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelDescription(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Show all logs including debug information"
|
||||||
|
case 1: return "Show informational messages and above"
|
||||||
|
case 2: return "Show notable events and above"
|
||||||
|
case 3: return "Show warnings and errors only"
|
||||||
|
case 4: return "Show errors and critical issues only"
|
||||||
|
case 5: return "Show only critical issues"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
LoggingConfigurationView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
readeck/UI/Settings/MarkdownContentView.swift
Normal file
35
readeck/UI/Settings/MarkdownContentView.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import MarkdownUI
|
||||||
|
|
||||||
|
/// A custom view that renders Markdown content using the MarkdownUI library.
|
||||||
|
/// This view encapsulates the Markdown rendering logic, making it easy to swap
|
||||||
|
/// the underlying Markdown library if needed in the future.
|
||||||
|
struct MarkdownContentView: View {
|
||||||
|
let content: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Markdown(content)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ScrollView {
|
||||||
|
MarkdownContentView(content: """
|
||||||
|
# Heading 1
|
||||||
|
|
||||||
|
This is a paragraph with **bold** and *italic* text.
|
||||||
|
|
||||||
|
## Heading 2
|
||||||
|
|
||||||
|
- List item 1
|
||||||
|
- List item 2
|
||||||
|
- List item 3
|
||||||
|
|
||||||
|
### Heading 3
|
||||||
|
|
||||||
|
Another paragraph with [a link](https://example.com).
|
||||||
|
""")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
55
readeck/UI/Settings/ReadingSettingsView.swift
Normal file
55
readeck/UI/Settings/ReadingSettingsView.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// ReadingSettingsView.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 08.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReadingSettingsView: View {
|
||||||
|
@State private var viewModel: SettingsGeneralViewModel
|
||||||
|
|
||||||
|
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
||||||
|
.onChange(of: viewModel.enableTTS) {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveGeneralSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||||
|
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||||
|
#endif
|
||||||
|
} header: {
|
||||||
|
Text("Reading Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadGeneralSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
List {
|
||||||
|
ReadingSettingsView(viewModel: .init(
|
||||||
|
MockUseCaseFactory()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
@ -1,56 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension AttributedString {
|
|
||||||
init(styledMarkdown markdownString: String) throws {
|
|
||||||
var output = try AttributedString(
|
|
||||||
markdown: markdownString,
|
|
||||||
options: .init(
|
|
||||||
allowsExtendedAttributes: true,
|
|
||||||
interpretedSyntax: .full,
|
|
||||||
failurePolicy: .returnPartiallyParsedIfPossible
|
|
||||||
),
|
|
||||||
baseURL: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
|
|
||||||
guard let intentBlock = intentBlock else { continue }
|
|
||||||
for intent in intentBlock.components {
|
|
||||||
switch intent.kind {
|
|
||||||
case .header(level: let level):
|
|
||||||
switch level {
|
|
||||||
case 1:
|
|
||||||
output[intentRange].font = .system(.title).bold()
|
|
||||||
case 2:
|
|
||||||
output[intentRange].font = .system(.title2).bold()
|
|
||||||
case 3:
|
|
||||||
output[intentRange].font = .system(.title3).bold()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if intentRange.lowerBound != output.startIndex {
|
|
||||||
output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self = output
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReleaseNotesView: View {
|
struct ReleaseNotesView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if let attributedString = loadReleaseNotes() {
|
if let markdownContent = loadReleaseNotes() {
|
||||||
Text(attributedString)
|
MarkdownContentView(content: markdownContent)
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
Text("Unable to load release notes")
|
Text("Unable to load release notes")
|
||||||
@ -71,13 +29,12 @@ struct ReleaseNotesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadReleaseNotes() -> AttributedString? {
|
private func loadReleaseNotes() -> String? {
|
||||||
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
||||||
let markdownContent = try? String(contentsOf: url),
|
let markdownContent = try? String(contentsOf: url) else {
|
||||||
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return attributedString
|
return markdownContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,141 +8,122 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsContainerView: View {
|
struct SettingsContainerView: View {
|
||||||
|
|
||||||
private var appVersion: String {
|
private var appVersion: String {
|
||||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
||||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
||||||
return "v\(version) (\(build))"
|
return "v\(version) (\(build))"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
List {
|
||||||
LazyVStack(spacing: 20) {
|
AppearanceSettingsView()
|
||||||
FontSettingsView()
|
|
||||||
.cardStyle()
|
ReadingSettingsView()
|
||||||
|
|
||||||
AppearanceSettingsView()
|
CacheSettingsView()
|
||||||
.cardStyle()
|
|
||||||
|
SyncSettingsView()
|
||||||
CacheSettingsView()
|
|
||||||
.cardStyle()
|
SettingsServerView()
|
||||||
|
|
||||||
SettingsGeneralView()
|
LegalPrivacySettingsView()
|
||||||
.cardStyle()
|
|
||||||
|
// Debug-only Logging Configuration
|
||||||
SettingsServerView()
|
#if DEBUG
|
||||||
.cardStyle()
|
if Bundle.main.isDebugBuild {
|
||||||
|
debugSettingsSection
|
||||||
LegalPrivacySettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
|
|
||||||
// Debug-only Logging Configuration
|
|
||||||
if Bundle.main.isDebugBuild {
|
|
||||||
debugSettingsSection
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
#endif
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
|
// App Info Section
|
||||||
AppInfo()
|
appInfoSection
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.listStyle(.insetGrouped)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var debugSettingsSection: some View {
|
private var debugSettingsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
Section {
|
||||||
|
SettingsRowNavigationLink(
|
||||||
|
icon: "list.bullet.rectangle",
|
||||||
|
iconColor: .blue,
|
||||||
|
title: "Debug Logs",
|
||||||
|
subtitle: "View all debug messages"
|
||||||
|
) {
|
||||||
|
DebugLogViewer()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsRowNavigationLink(
|
||||||
|
icon: "slider.horizontal.3",
|
||||||
|
iconColor: .purple,
|
||||||
|
title: "Logging Configuration",
|
||||||
|
subtitle: "Configure log levels and categories"
|
||||||
|
) {
|
||||||
|
LoggingConfigurationView()
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "ant.fill")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text("Debug Settings")
|
Text("Debug Settings")
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("DEBUG BUILD")
|
Text("DEBUG BUILD")
|
||||||
.font(.caption)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 2)
|
||||||
.background(Color.orange.opacity(0.2))
|
.background(Color.orange.opacity(0.2))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
NavigationLink {
|
}
|
||||||
LoggingConfigurationView()
|
|
||||||
} label: {
|
@ViewBuilder
|
||||||
HStack {
|
private var appInfoSection: some View {
|
||||||
Image(systemName: "doc.text.magnifyingglass")
|
Section {
|
||||||
.foregroundColor(.blue)
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
.frame(width: 24)
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.font(.caption)
|
||||||
Text("Logging Configuration")
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.primary)
|
Text("Version \(appVersion)")
|
||||||
Text("Configure log levels and categories")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.cardStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func AppInfo() -> some View {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("Version \(appVersion)")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "person.crop.circle")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "person.crop.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
Text("Developer:")
|
Text("Developer:")
|
||||||
.font(.footnote)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("Ilyas Hallak") {
|
Button("Ilyas Hallak") {
|
||||||
if let url = URL(string: "https://ilyashallak.de") {
|
if let url = URL(string: "https://ilyashallak.de") {
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.footnote)
|
.font(.caption)
|
||||||
.foregroundColor(.blue)
|
}
|
||||||
.underline()
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("From Bremen with 💚")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
Image(systemName: "globe")
|
.listRowBackground(Color.clear)
|
||||||
.foregroundColor(.secondary)
|
.padding(.vertical, 8)
|
||||||
Text("From Bremen with 💚")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.top, 16)
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card Modifier für einheitlichen Look
|
// Card Modifier für einheitlichen Look (kept for backwards compatibility with other views)
|
||||||
extension View {
|
extension View {
|
||||||
func cardStyle() -> some View {
|
func cardStyle() -> some View {
|
||||||
self
|
self
|
||||||
@ -154,5 +135,7 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsContainerView()
|
NavigationStack {
|
||||||
}
|
SettingsContainerView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -16,15 +16,8 @@ struct SettingsGeneralView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "General Settings".localized, icon: "gear")
|
Section {
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("General")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
// What's New Button
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showReleaseNotes = true
|
showReleaseNotes = true
|
||||||
}) {
|
}) {
|
||||||
@ -39,83 +32,57 @@ struct SettingsGeneralView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
||||||
.toggleStyle(.switch)
|
|
||||||
.onChange(of: viewModel.enableTTS) {
|
.onChange(of: viewModel.enableTTS) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.saveGeneralSettings()
|
await viewModel.saveGeneralSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("General")
|
||||||
|
} footer: {
|
||||||
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
|
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
|
||||||
.font(.footnote)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reading Settings
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Open external links in".localized)
|
|
||||||
.font(.headline)
|
|
||||||
Picker("urlOpener", selection: $viewModel.urlOpener) {
|
|
||||||
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
|
|
||||||
Text(urlOpener.displayName.localized).tag(urlOpener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.onChange(of: viewModel.urlOpener) {
|
|
||||||
Task {
|
|
||||||
await viewModel.saveGeneralSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Sync Settings
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Sync Settings")
|
|
||||||
.font(.headline)
|
|
||||||
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
|
||||||
if viewModel.autoSyncEnabled {
|
if viewModel.autoSyncEnabled {
|
||||||
HStack {
|
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
||||||
Text("Sync interval")
|
|
||||||
Spacer()
|
|
||||||
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Sync Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reading Settings
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Reading Settings")
|
|
||||||
.font(.headline)
|
|
||||||
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
|
||||||
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
} header: {
|
||||||
|
Text("Reading Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages
|
|
||||||
if let successMessage = viewModel.successMessage {
|
if let successMessage = viewModel.successMessage {
|
||||||
HStack {
|
Section {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
HStack {
|
||||||
.foregroundColor(.green)
|
Image(systemName: "checkmark.circle.fill")
|
||||||
Text(successMessage)
|
.foregroundColor(.green)
|
||||||
.foregroundColor(.green)
|
Text(successMessage)
|
||||||
.font(.caption)
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
HStack {
|
Section {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
HStack {
|
||||||
.foregroundColor(.red)
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
Text(errorMessage)
|
.foregroundColor(.red)
|
||||||
.foregroundColor(.red)
|
Text(errorMessage)
|
||||||
.font(.caption)
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showReleaseNotes) {
|
.sheet(isPresented: $showReleaseNotes) {
|
||||||
ReleaseNotesView()
|
ReleaseNotesView()
|
||||||
@ -127,7 +94,10 @@ struct SettingsGeneralView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsGeneralView(viewModel: .init(
|
List {
|
||||||
MockUseCaseFactory()
|
SettingsGeneralView(viewModel: .init(
|
||||||
))
|
MockUseCaseFactory()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,151 +10,34 @@ import SwiftUI
|
|||||||
struct SettingsServerView: View {
|
struct SettingsServerView: View {
|
||||||
@State private var viewModel = SettingsServerViewModel()
|
@State private var viewModel = SettingsServerViewModel()
|
||||||
@State private var showingLogoutAlert = false
|
@State private var showingLogoutAlert = false
|
||||||
|
|
||||||
init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) {
|
|
||||||
self.viewModel = viewModel
|
|
||||||
self.showingLogoutAlert = showingLogoutAlert
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Section {
|
||||||
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
|
SettingsRowValue(
|
||||||
.padding(.bottom, 4)
|
icon: "server.rack",
|
||||||
|
title: "Server",
|
||||||
Text(viewModel.isSetupMode ?
|
value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint
|
||||||
"Enter your Readeck server details to get started." :
|
)
|
||||||
"Your current server connection and login credentials.")
|
|
||||||
.font(.body)
|
SettingsRowValue(
|
||||||
.foregroundColor(.secondary)
|
icon: "person.circle.fill",
|
||||||
.multilineTextAlignment(.center)
|
title: "Username",
|
||||||
.padding(.bottom, 8)
|
value: viewModel.username.isEmpty ? "Not set" : viewModel.username
|
||||||
|
)
|
||||||
// Form
|
|
||||||
VStack(spacing: 16) {
|
SettingsRowButton(
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
icon: "rectangle.portrait.and.arrow.right",
|
||||||
Text("Server Endpoint")
|
iconColor: .red,
|
||||||
.font(.headline)
|
title: "Logout",
|
||||||
if viewModel.isSetupMode {
|
subtitle: nil,
|
||||||
TextField("https://readeck.example.com", text: $viewModel.endpoint)
|
destructive: true
|
||||||
.textFieldStyle(.roundedBorder)
|
) {
|
||||||
.keyboardType(.URL)
|
showingLogoutAlert = true
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.onChange(of: viewModel.endpoint) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "server.rack")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text(viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint)
|
|
||||||
.foregroundColor(viewModel.endpoint.isEmpty ? .secondary : .primary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Username")
|
|
||||||
.font(.headline)
|
|
||||||
if viewModel.isSetupMode {
|
|
||||||
TextField("Your Username", text: $viewModel.username)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.onChange(of: viewModel.username) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "person.circle.fill")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text(viewModel.username.isEmpty ? "Not set" : viewModel.username)
|
|
||||||
.foregroundColor(viewModel.username.isEmpty ? .secondary : .primary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if viewModel.isSetupMode {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Password")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
SecureField("Your Password", text: $viewModel.password)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.onChange(of: viewModel.password) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let successMessage = viewModel.successMessage {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.green)
|
|
||||||
Text(successMessage)
|
|
||||||
.foregroundColor(.green)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.isSetupMode {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Button(action: {
|
|
||||||
Task {
|
|
||||||
await viewModel.saveServerSettings()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
}
|
|
||||||
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(action: {
|
|
||||||
showingLogoutAlert = true
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
|
||||||
.font(.caption)
|
|
||||||
Text("Logout")
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color(.systemGray5))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Server Connection")
|
||||||
|
} footer: {
|
||||||
|
Text("Your current server connection and login credentials.")
|
||||||
}
|
}
|
||||||
.alert("Logout", isPresented: $showingLogoutAlert) {
|
.alert("Logout", isPresented: $showingLogoutAlert) {
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
@ -173,7 +56,8 @@ struct SettingsServerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsServerView(viewModel: .init(
|
List {
|
||||||
MockUseCaseFactory()
|
SettingsServerView()
|
||||||
))
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,8 +62,15 @@ class SettingsServerViewModel {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
let user = try await loginUseCase.execute(endpoint: endpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
|
// Normalize endpoint before saving
|
||||||
try await saveServerSettingsUseCase.execute(endpoint: endpoint, username: username, password: password, token: user.token)
|
let normalizedEndpoint = normalizeEndpoint(endpoint)
|
||||||
|
|
||||||
|
let user = try await loginUseCase.execute(endpoint: normalizedEndpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
|
||||||
|
try await saveServerSettingsUseCase.execute(endpoint: normalizedEndpoint, username: username, password: password, token: user.token)
|
||||||
|
|
||||||
|
// Update local endpoint with normalized version
|
||||||
|
endpoint = normalizedEndpoint
|
||||||
|
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
successMessage = "Server settings saved and successfully logged in."
|
successMessage = "Server settings saved and successfully logged in."
|
||||||
try await SettingsRepository().saveHasFinishedSetup(true)
|
try await SettingsRepository().saveHasFinishedSetup(true)
|
||||||
@ -73,6 +80,51 @@ class SettingsServerViewModel {
|
|||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Endpoint Normalization
|
||||||
|
|
||||||
|
private func normalizeEndpoint(_ endpoint: String) -> String {
|
||||||
|
var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
// Remove query parameters
|
||||||
|
if let queryIndex = normalized.firstIndex(of: "?") {
|
||||||
|
normalized = String(normalized[..<queryIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URL components
|
||||||
|
guard var urlComponents = URLComponents(string: normalized) else {
|
||||||
|
// If parsing fails, try adding https:// and parse again
|
||||||
|
normalized = "https://" + normalized
|
||||||
|
guard var urlComponents = URLComponents(string: normalized) else {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return buildNormalizedURL(from: urlComponents)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildNormalizedURL(from: urlComponents)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildNormalizedURL(from components: URLComponents) -> String {
|
||||||
|
var urlComponents = components
|
||||||
|
|
||||||
|
// Ensure scheme is http or https, default to https
|
||||||
|
if urlComponents.scheme == nil {
|
||||||
|
urlComponents.scheme = "https"
|
||||||
|
} else if urlComponents.scheme != "http" && urlComponents.scheme != "https" {
|
||||||
|
urlComponents.scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash from path if present
|
||||||
|
if urlComponents.path.hasSuffix("/") {
|
||||||
|
urlComponents.path = String(urlComponents.path.dropLast())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove query parameters (already done above, but double check)
|
||||||
|
urlComponents.query = nil
|
||||||
|
urlComponents.fragment = nil
|
||||||
|
|
||||||
|
return urlComponents.string ?? components.string ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func logout() async {
|
func logout() async {
|
||||||
|
|||||||
64
readeck/UI/Settings/SyncSettingsView.swift
Normal file
64
readeck/UI/Settings/SyncSettingsView.swift
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// SyncSettingsView.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 08.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SyncSettingsView: View {
|
||||||
|
@State private var viewModel: SettingsGeneralViewModel
|
||||||
|
|
||||||
|
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
#if DEBUG
|
||||||
|
Section {
|
||||||
|
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
||||||
|
if viewModel.autoSyncEnabled {
|
||||||
|
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Sync Settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text(successMessage)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadGeneralSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
List {
|
||||||
|
SyncSettingsView(viewModel: .init(
|
||||||
|
MockUseCaseFactory()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
@ -7,14 +7,12 @@ extension Notification.Name {
|
|||||||
|
|
||||||
// MARK: - Authentication
|
// MARK: - Authentication
|
||||||
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
|
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
|
||||||
|
|
||||||
// MARK: - Network
|
|
||||||
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
|
|
||||||
|
|
||||||
// MARK: - UI Interactions
|
// MARK: - UI Interactions
|
||||||
static let dismissKeyboard = Notification.Name("DismissKeyboard")
|
static let dismissKeyboard = Notification.Name("DismissKeyboard")
|
||||||
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
|
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
|
||||||
|
|
||||||
// MARK: - User Preferences
|
// MARK: - User Preferences
|
||||||
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
|
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
|
||||||
|
static let tagSortOrderChanged = Notification.Name("tagSortOrderChanged")
|
||||||
}
|
}
|
||||||
@ -44,4 +44,12 @@ struct URLUtil {
|
|||||||
guard let url = URL(string: urlString), let host = url.host else { return nil }
|
guard let url = URL(string: urlString), let host = url.host else { return nil }
|
||||||
return host.replacingOccurrences(of: "www.", with: "")
|
return host.replacingOccurrences(of: "www.", with: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func openUrlLabel(for urlString: String) -> String {
|
||||||
|
if let domain = extractDomain(from: urlString) {
|
||||||
|
return String(format: "open_url".localized, domain)
|
||||||
|
} else {
|
||||||
|
return "open_original_page".localized
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,9 @@ import netfox
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct readeckApp: App {
|
struct readeckApp: App {
|
||||||
@StateObject private var appViewModel = AppViewModel()
|
@State private var appViewModel = AppViewModel()
|
||||||
@StateObject private var appSettings = AppSettings()
|
@StateObject private var appSettings = AppSettings()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@ -19,18 +20,17 @@ struct readeckApp: App {
|
|||||||
if appViewModel.hasFinishedSetup {
|
if appViewModel.hasFinishedSetup {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
} else {
|
} else {
|
||||||
SettingsServerView()
|
OnboardingServerView()
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environmentObject(appSettings)
|
.environmentObject(appSettings)
|
||||||
|
.environment(\.managedObjectContext, CoreDataManager.shared.context)
|
||||||
.preferredColorScheme(appSettings.theme.colorScheme)
|
.preferredColorScheme(appSettings.theme.colorScheme)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
NFX.sharedInstance().start()
|
NFX.sharedInstance().start()
|
||||||
#endif
|
#endif
|
||||||
// Initialize server connectivity monitoring
|
|
||||||
_ = ServerConnectivity.shared
|
|
||||||
Task {
|
Task {
|
||||||
await loadAppSettings()
|
await loadAppSettings()
|
||||||
}
|
}
|
||||||
@ -40,6 +40,13 @@ struct readeckApp: App {
|
|||||||
await loadAppSettings()
|
await loadAppSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||||
|
if newPhase == .active {
|
||||||
|
Task {
|
||||||
|
await appViewModel.onAppResume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
145
readeck/Utils/LogStore.swift
Normal file
145
readeck/Utils/LogStore.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// LogStore.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Log Entry
|
||||||
|
|
||||||
|
struct LogEntry: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let timestamp: Date
|
||||||
|
let level: LogLevel
|
||||||
|
let category: LogCategory
|
||||||
|
let message: String
|
||||||
|
let file: String
|
||||||
|
let function: String
|
||||||
|
let line: Int
|
||||||
|
|
||||||
|
var fileName: String {
|
||||||
|
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedTimestamp: String {
|
||||||
|
DateFormatter.logTimestamp.string(from: timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
timestamp: Date = Date(),
|
||||||
|
level: LogLevel,
|
||||||
|
category: LogCategory,
|
||||||
|
message: String,
|
||||||
|
file: String,
|
||||||
|
function: String,
|
||||||
|
line: Int
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.level = level
|
||||||
|
self.category = category
|
||||||
|
self.message = message
|
||||||
|
self.file = file
|
||||||
|
self.function = function
|
||||||
|
self.line = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log Store
|
||||||
|
|
||||||
|
actor LogStore {
|
||||||
|
static let shared = LogStore()
|
||||||
|
|
||||||
|
private var entries: [LogEntry] = []
|
||||||
|
private let maxEntries: Int
|
||||||
|
|
||||||
|
private init(maxEntries: Int = 1000) {
|
||||||
|
self.maxEntries = maxEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntry(_ entry: LogEntry) {
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
// Keep only the most recent entries
|
||||||
|
if entries.count > maxEntries {
|
||||||
|
entries.removeFirst(entries.count - maxEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEntries() -> [LogEntry] {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEntries(
|
||||||
|
level: LogLevel? = nil,
|
||||||
|
category: LogCategory? = nil,
|
||||||
|
searchText: String? = nil
|
||||||
|
) -> [LogEntry] {
|
||||||
|
var filtered = entries
|
||||||
|
|
||||||
|
if let level = level {
|
||||||
|
filtered = filtered.filter { $0.level == level }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let category = category {
|
||||||
|
filtered = filtered.filter { $0.category == category }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let searchText = searchText, !searchText.isEmpty {
|
||||||
|
filtered = filtered.filter {
|
||||||
|
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
entries.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportAsText() -> String {
|
||||||
|
var text = "Readeck Debug Logs\n"
|
||||||
|
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
|
||||||
|
text += "Total Entries: \(entries.count)\n"
|
||||||
|
text += String(repeating: "=", count: 80) + "\n\n"
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
text += "[\(entry.formattedTimestamp)] "
|
||||||
|
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
|
||||||
|
text += "[\(entry.category.rawValue)] "
|
||||||
|
text += "\(entry.fileName):\(entry.line) "
|
||||||
|
text += "\(entry.function)\n"
|
||||||
|
text += " \(entry.message)\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "DEBUG"
|
||||||
|
case 1: return "INFO"
|
||||||
|
case 2: return "NOTICE"
|
||||||
|
case 3: return "WARNING"
|
||||||
|
case 4: return "ERROR"
|
||||||
|
case 5: return "CRITICAL"
|
||||||
|
default: return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DateFormatter Extension
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
static let exportTimestamp: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
@ -10,14 +10,14 @@ import os
|
|||||||
|
|
||||||
// MARK: - Log Configuration
|
// MARK: - Log Configuration
|
||||||
|
|
||||||
enum LogLevel: Int, CaseIterable {
|
enum LogLevel: Int, CaseIterable, Codable {
|
||||||
case debug = 0
|
case debug = 0
|
||||||
case info = 1
|
case info = 1
|
||||||
case notice = 2
|
case notice = 2
|
||||||
case warning = 3
|
case warning = 3
|
||||||
case error = 4
|
case error = 4
|
||||||
case critical = 5
|
case critical = 5
|
||||||
|
|
||||||
var emoji: String {
|
var emoji: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .debug: return "🔍"
|
case .debug: return "🔍"
|
||||||
@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogCategory: String, CaseIterable {
|
enum LogCategory: String, CaseIterable, Codable {
|
||||||
case network = "Network"
|
case network = "Network"
|
||||||
case ui = "UI"
|
case ui = "UI"
|
||||||
case data = "Data"
|
case data = "Data"
|
||||||
@ -43,14 +43,28 @@ enum LogCategory: String, CaseIterable {
|
|||||||
|
|
||||||
class LogConfiguration: ObservableObject {
|
class LogConfiguration: ObservableObject {
|
||||||
static let shared = LogConfiguration()
|
static let shared = LogConfiguration()
|
||||||
|
|
||||||
@Published private var categoryLevels: [LogCategory: LogLevel] = [:]
|
@Published private var categoryLevels: [LogCategory: LogLevel] = [:]
|
||||||
@Published var globalMinLevel: LogLevel = .debug
|
@Published var globalMinLevel: LogLevel = .debug
|
||||||
@Published var showPerformanceLogs = true
|
@Published var showPerformanceLogs = true
|
||||||
@Published var showTimestamps = true
|
@Published var showTimestamps = true
|
||||||
@Published var includeSourceLocation = true
|
@Published var includeSourceLocation = true
|
||||||
|
@Published var isLoggingEnabled = false
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
// First time setup: Enable logging in DEBUG builds with sensible defaults
|
||||||
|
#if DEBUG
|
||||||
|
if UserDefaults.standard.object(forKey: "LogConfigurationInitialized") == nil {
|
||||||
|
isLoggingEnabled = true
|
||||||
|
showPerformanceLogs = true
|
||||||
|
showTimestamps = true
|
||||||
|
includeSourceLocation = true
|
||||||
|
globalMinLevel = .debug
|
||||||
|
UserDefaults.standard.set(true, forKey: "LogConfigurationInitialized")
|
||||||
|
saveConfiguration()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
loadConfiguration()
|
loadConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +78,7 @@ class LogConfiguration: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
|
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
|
||||||
|
guard isLoggingEnabled else { return false }
|
||||||
let categoryLevel = getLevel(for: category)
|
let categoryLevel = getLevel(for: category)
|
||||||
return level.rawValue >= categoryLevel.rawValue
|
return level.rawValue >= categoryLevel.rawValue
|
||||||
}
|
}
|
||||||
@ -79,11 +94,22 @@ class LogConfiguration: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug
|
globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug
|
||||||
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
|
|
||||||
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
|
// Load boolean settings with defaults
|
||||||
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
|
if UserDefaults.standard.object(forKey: "LogShowPerformance") != nil {
|
||||||
|
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
|
||||||
|
}
|
||||||
|
if UserDefaults.standard.object(forKey: "LogShowTimestamps") != nil {
|
||||||
|
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
|
||||||
|
}
|
||||||
|
if UserDefaults.standard.object(forKey: "LogIncludeSourceLocation") != nil {
|
||||||
|
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
|
||||||
|
}
|
||||||
|
if UserDefaults.standard.object(forKey: "LogIsEnabled") != nil {
|
||||||
|
isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveConfiguration() {
|
private func saveConfiguration() {
|
||||||
@ -96,6 +122,7 @@ class LogConfiguration: ObservableObject {
|
|||||||
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
|
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
|
||||||
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
|
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
|
||||||
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
|
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
|
||||||
|
UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,41 +137,66 @@ struct Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Log Levels
|
// MARK: - Log Levels
|
||||||
|
|
||||||
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.debug, for: category) else { return }
|
guard config.shouldLog(.debug, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
|
||||||
logger.debug("\(formattedMessage)")
|
logger.debug("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .debug, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.info, for: category) else { return }
|
guard config.shouldLog(.info, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
|
||||||
logger.info("\(formattedMessage)")
|
logger.info("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .info, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.notice, for: category) else { return }
|
guard config.shouldLog(.notice, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
|
||||||
logger.notice("\(formattedMessage)")
|
logger.notice("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .notice, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.warning, for: category) else { return }
|
guard config.shouldLog(.warning, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
|
||||||
logger.warning("\(formattedMessage)")
|
logger.warning("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .warning, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.error, for: category) else { return }
|
guard config.shouldLog(.error, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
|
||||||
logger.error("\(formattedMessage)")
|
logger.error("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .error, file: file, function: function, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.critical, for: category) else { return }
|
guard config.shouldLog(.critical, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
|
||||||
logger.critical("\(formattedMessage)")
|
logger.critical("\(formattedMessage)")
|
||||||
|
storeLog(message: message, level: .critical, file: file, function: function, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Store Log
|
||||||
|
|
||||||
|
private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) {
|
||||||
|
#if DEBUG
|
||||||
|
guard config.isLoggingEnabled else { return }
|
||||||
|
let entry = LogEntry(
|
||||||
|
level: level,
|
||||||
|
category: category,
|
||||||
|
message: message,
|
||||||
|
file: file,
|
||||||
|
function: function,
|
||||||
|
line: line
|
||||||
|
)
|
||||||
|
Task {
|
||||||
|
await LogStore.shared.addEntry(entry)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Convenience Methods
|
// MARK: - Convenience Methods
|
||||||
@ -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