Compare commits
115 Commits
v1.0.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db4ce757ee | |||
| 59acfa79ac | |||
| 6de413376f | |||
| d7fcacfd34 | |||
| ad606d528c | |||
| b98c71b8b3 | |||
| a3b3863fa3 | |||
| 4134b41be2 | |||
| e5d4e6d8a0 | |||
| 4b788650b8 | |||
| f3719fa9d4 | |||
| 460b05ef34 | |||
| 7338db5fab | |||
| 4b93c605f1 | |||
| 589fcdb2b4 | |||
| 4e973751f4 | |||
| 571d61c8e5 | |||
| 05ae421a40 | |||
| 85bad35788 | |||
| db3cbf41d4 | |||
| cdfa6dc4c5 | |||
| 87464943ac | |||
| 580b968b89 | |||
| 2dec340c85 | |||
| 9edf984be1 | |||
| c610fda731 | |||
| 293ac87b7c | |||
| e6a884b160 | |||
| fef1876297 | |||
| 907cc9220f | |||
| c629894611 | |||
| b77e4e3e9f | |||
| 1b9f79bccc | |||
| d1157defbe | |||
| a041300b4f | |||
| ec12815a51 | |||
| cf06a3147d | |||
| 47f8f73664 | |||
| d97e404cc7 | |||
| 6906509aea | |||
| afe3d1e261 | |||
| 554e223bbc | |||
| 819eb4fc56 | |||
| 6385d10317 | |||
| 31ed3fc0e1 | |||
| 04de2c20d4 | |||
| fde1140f24 | |||
| e5334d456d | |||
| 1957995a9e | |||
| bf3ee7a1d7 | |||
| ef8ebd6f00 | |||
| eddc8a35ff | |||
| 446be3424e | |||
| b8e5766cb1 | |||
| e61dbc7d72 | |||
| f302f8800f | |||
| 3d4c695ffa | |||
| a5d94d1aee | |||
| bef6a9dc2f | |||
| 4595a9b69f | |||
| 4c744e6d10 | |||
| 615abf1d74 | |||
| 969f80c0a5 | |||
| 842c404f04 | |||
| 614042c3bd | |||
| 008303d043 | |||
| 37321f31c9 | |||
| e9195351aa | |||
| a782a27eea | |||
| 5c9c00134a | |||
| 0a53705df1 | |||
| 32dbab400e | |||
| 171bf881fb | |||
| 6addacb1d9 | |||
| 2834102d45 | |||
| 7b12bb4cf5 | |||
| a2c805b700 | |||
| ad7ac19d79 | |||
| 080c5aa4d2 | |||
| f3d52b3c3a | |||
| a651398dca | |||
| 58b89d4c86 | |||
| 62f2f07f38 | |||
| 99ef722e7d | |||
| 3ea4e49686 | |||
| f42d138f58 | |||
| f50ad505ae | |||
| 4c180c6a81 | |||
| 8739716348 | |||
| c8c93b76da | |||
| 3abeb3f3e4 | |||
| f3147a6cc6 | |||
| ac7f4e66eb | |||
|
|
413d3843aa | ||
|
|
b929611430 | ||
|
|
d369791f27 | ||
| 2791b7f227 | |||
| 52bf16a8eb | |||
| 051b5b169d | |||
| d6ea56cfa9 | |||
|
|
f78de1f740 | ||
|
|
26990c59fa | ||
| 534ceddad4 | |||
| dcbe0515fc | |||
| ba74430d10 | |||
| fbf840888a | |||
| c13fc107b1 | |||
| f40c5597f3 | |||
| 5947312339 | |||
| 5b520995ac | |||
| 8fb2a2a14e | |||
| df8a7b64b2 | |||
| 680a9562be | |||
| 2f55da92c0 | |||
| 953ff5da8d |
6
.gitignore
vendored
6
.gitignore
vendored
@ -63,3 +63,9 @@ fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/.env.default
|
||||
fastlane/AuthKey_JZJCQWW9N3.p8
|
||||
|
||||
# Documentation
|
||||
documentation/
|
||||
|
||||
# macOS
|
||||
**/.DS_Store
|
||||
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
|
||||
|
||||
48
README.md
48
README.md
@ -7,33 +7,49 @@ A native iOS client for [readeck](https://readeck.org) bookmark management.
|
||||
The official repository is on Codeberg:
|
||||
https://codeberg.org/readeck/readeck
|
||||
|
||||
## Screenshots
|
||||
## Download
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/main.webp" height="400" alt="Main View">
|
||||
<img src="screenshots/detail.webp" height="400" alt="Detail View">
|
||||
<img src="screenshots/new.webp" height="400" alt="Add Bookmark">
|
||||
<img src="screenshots/more.webp" height="400" alt="More Options">
|
||||
<img src="screenshots/share.webp" height="400" alt="Share Extension">
|
||||
<img src="screenshots/ipad.webp" height="400" alt="iPad View">
|
||||
</p>
|
||||
|
||||
## TestFlight Beta Access
|
||||
|
||||
You can now join the public TestFlight beta for the Readeck iOS app:
|
||||
### App Store (Stable Releases)
|
||||
<a href="https://apps.apple.com/de/app/readeck/id6748764703">
|
||||
<img src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" alt="Download on the App Store" width="200">
|
||||
</a>
|
||||
|
||||
### TestFlight Beta Access (Early Releases)
|
||||
[Join the Readeck Beta on TestFlight](https://testflight.apple.com/join/cV55mKsR)
|
||||
|
||||
To participate, simply install TestFlight from the App Store and open the link above on your iPhone, iPad, or Mac. This early version lets you explore all core features before the official release. Your feedback is incredibly valuable and will help shape the final app.
|
||||
For early access to new features and beta versions (use with caution). To participate, simply install TestFlight from the App Store and open the link above on your iPhone, iPad, or Mac. This early version lets you explore all core features before the official release. Your feedback is incredibly valuable and will help shape the final app.
|
||||
|
||||
What to test:
|
||||
- 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!
|
||||
|
||||
If you are interested in joining the internal beta, please contact me directly at mooonki:matrix.org.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### iPhone
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/iphone_1.png" height="400" alt="iPhone Screenshot 1">
|
||||
<img src="screenshots/iphone_2.png" height="400" alt="iPhone Screenshot 2">
|
||||
<img src="screenshots/iphone_3.png" height="400" alt="iPhone Screenshot 3">
|
||||
<img src="screenshots/iphone_4.png" height="400" alt="iPhone Screenshot 4">
|
||||
<img src="screenshots/iphone_5.png" height="400" alt="iPhone Screenshot 5">
|
||||
</p>
|
||||
|
||||
### iPad
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/ipad_1.jpg" height="400" alt="iPad Screenshot 1">
|
||||
<img src="screenshots/ipad_2.jpg" height="400" alt="iPad Screenshot 2">
|
||||
<img src="screenshots/ipad_3.jpg" height="400" alt="iPad Screenshot 3">
|
||||
<img src="screenshots/ipad_4.jpg" height="400" alt="iPad Screenshot 4">
|
||||
<img src="screenshots/ipad_5.jpg" height="400" alt="iPad Screenshot 5">
|
||||
</p>
|
||||
|
||||
|
||||
## Core Features
|
||||
|
||||
- Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
|
||||
@ -68,7 +84,7 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
|
||||
|
||||
## Versions
|
||||
|
||||
[see Changelog](./CHANGELOG.md)
|
||||
[see Release Notes](./readeck/UI/Resources/RELEASE_NOTES.md)
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class OfflineBookmarkManager {
|
||||
class OfflineBookmarkManager: @unchecked Sendable {
|
||||
static let shared = OfflineBookmarkManager()
|
||||
|
||||
private init() {}
|
||||
@ -17,27 +17,31 @@ class OfflineBookmarkManager {
|
||||
func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool {
|
||||
let tagsString = tags.joined(separator: ",")
|
||||
|
||||
// Check if URL already exists offline
|
||||
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "url == %@", url)
|
||||
|
||||
do {
|
||||
let existingEntities = try context.fetch(fetchRequest)
|
||||
if let existingEntity = existingEntities.first {
|
||||
// Update existing entry
|
||||
existingEntity.tags = tagsString
|
||||
existingEntity.title = title
|
||||
} else {
|
||||
// Create new entry
|
||||
let entity = ArticleURLEntity(context: context)
|
||||
entity.id = UUID()
|
||||
entity.url = url
|
||||
entity.title = title
|
||||
entity.tags = tagsString
|
||||
try context.safePerform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check if URL already exists offline
|
||||
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "url == %@", url)
|
||||
|
||||
let existingEntities = try self.context.fetch(fetchRequest)
|
||||
if let existingEntity = existingEntities.first {
|
||||
// Update existing entry
|
||||
existingEntity.tags = tagsString
|
||||
existingEntity.title = title
|
||||
} else {
|
||||
// Create new entry
|
||||
let entity = ArticleURLEntity(context: self.context)
|
||||
entity.id = UUID()
|
||||
entity.url = url
|
||||
entity.title = title
|
||||
entity.tags = tagsString
|
||||
}
|
||||
|
||||
try self.context.save()
|
||||
print("Bookmark saved offline: \(url)")
|
||||
}
|
||||
|
||||
try context.save()
|
||||
print("Bookmark saved offline: \(url)")
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to save offline bookmark: \(error)")
|
||||
@ -45,16 +49,102 @@ class OfflineBookmarkManager {
|
||||
}
|
||||
}
|
||||
|
||||
func getTags() -> [String] {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
|
||||
func getTags() async -> [String] {
|
||||
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
|
||||
|
||||
do {
|
||||
let tagEntities = try context.fetch(fetchRequest)
|
||||
return tagEntities.compactMap { $0.name }.sorted()
|
||||
return try await backgroundContext.perform {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
|
||||
|
||||
let tagEntities = try backgroundContext.fetch(fetchRequest)
|
||||
return tagEntities.compactMap { $0.name }
|
||||
}
|
||||
} catch {
|
||||
print("Failed to fetch tags: \(error)")
|
||||
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,12 +1,15 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ShareBookmarkView: View {
|
||||
@ObservedObject var viewModel: ShareBookmarkViewModel
|
||||
@State private var keyboardHeight: CGFloat = 0
|
||||
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
||||
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
private func dismissKeyboard() {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
|
||||
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -39,7 +42,6 @@ struct ShareBookmarkView: View {
|
||||
saveButtonSection
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onAppear { viewModel.onAppear() }
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
@ -134,33 +136,31 @@ struct ShareBookmarkView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var tagManagementSection: some View {
|
||||
if !viewModel.labels.isEmpty || !viewModel.isServerReachable {
|
||||
TagManagementView(
|
||||
allLabels: convertToBookmarkLabels(viewModel.labels),
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: false,
|
||||
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
|
||||
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
|
||||
searchFieldFocus: $focusedField,
|
||||
onAddCustomTag: {
|
||||
addCustomTag()
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
if viewModel.selectedLabels.contains(label) {
|
||||
viewModel.selectedLabels.remove(label)
|
||||
} else {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
}
|
||||
viewModel.searchText = ""
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
CoreDataTagManagementView(
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
searchFieldFocus: $focusedField,
|
||||
fetchLimit: 150,
|
||||
sortOrder: viewModel.tagSortOrder,
|
||||
availableLabelsTitle: "Most used labels",
|
||||
context: viewContext,
|
||||
onAddCustomTag: {
|
||||
addCustomTag()
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
if viewModel.selectedLabels.contains(label) {
|
||||
viewModel.selectedLabels.remove(label)
|
||||
} else {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
}
|
||||
)
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
viewModel.searchText = ""
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
viewModel.selectedLabels.remove(label)
|
||||
}
|
||||
)
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -199,29 +199,8 @@ struct ShareBookmarkView: View {
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func convertToBookmarkLabels(_ dtos: [BookmarkLabelDto]) -> [BookmarkLabel] {
|
||||
return dtos.map { .init(name: $0.name, count: $0.count, href: $0.href) }
|
||||
}
|
||||
|
||||
private func convertToBookmarkLabelPages(_ dtoPages: [[BookmarkLabelDto]]) -> [[BookmarkLabel]] {
|
||||
return dtoPages.map { convertToBookmarkLabels($0) }
|
||||
}
|
||||
|
||||
|
||||
private func addCustomTag() {
|
||||
let trimmed = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let lowercased = trimmed.lowercased()
|
||||
let allExisting = Set(viewModel.labels.map { $0.name.lowercased() })
|
||||
let allSelected = Set(viewModel.selectedLabels.map { $0.lowercased() })
|
||||
|
||||
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
||||
// Tag already exists, don't add
|
||||
return
|
||||
} else {
|
||||
viewModel.selectedLabels.insert(trimmed)
|
||||
viewModel.searchText = ""
|
||||
}
|
||||
viewModel.addCustomTag(context: viewContext)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,70 +6,46 @@ import CoreData
|
||||
class ShareBookmarkViewModel: ObservableObject {
|
||||
@Published var url: String?
|
||||
@Published var title: String = ""
|
||||
@Published var labels: [BookmarkLabelDto] = []
|
||||
@Published var selectedLabels: Set<String> = []
|
||||
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
|
||||
@Published var isSaving: Bool = false
|
||||
@Published var searchText: String = ""
|
||||
@Published var isServerReachable: Bool = true
|
||||
let tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount
|
||||
let extensionContext: NSExtensionContext?
|
||||
|
||||
|
||||
private let logger = Logger.viewModel
|
||||
|
||||
// Computed properties for pagination
|
||||
var availableLabels: [BookmarkLabelDto] {
|
||||
return labels.filter { !selectedLabels.contains($0.name) }
|
||||
}
|
||||
|
||||
// Computed property for 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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let serverCheck = ShareExtensionServerCheck.shared
|
||||
private let tagRepository = TagRepository()
|
||||
|
||||
init(extensionContext: NSExtensionContext?) {
|
||||
self.extensionContext = extensionContext
|
||||
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
|
||||
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() {
|
||||
logger.debug("Starting to extract shared content")
|
||||
guard let extensionContext = extensionContext else {
|
||||
logger.warning("No extension context available for content extraction")
|
||||
return
|
||||
}
|
||||
|
||||
var extractedUrl: String?
|
||||
var extractedTitle: String?
|
||||
|
||||
for item in extensionContext.inputItems {
|
||||
guard let inputItem = item as? NSExtensionItem else { continue }
|
||||
|
||||
// Use the inputItem's attributedTitle or attributedContentText as potential title
|
||||
if let attributedTitle = inputItem.attributedTitle?.string, !attributedTitle.isEmpty {
|
||||
extractedTitle = attributedTitle
|
||||
logger.info("Extracted title from input item: \(attributedTitle)")
|
||||
} else if let attributedContent = inputItem.attributedContentText?.string, !attributedContent.isEmpty {
|
||||
extractedTitle = attributedContent
|
||||
logger.info("Extracted title from content text: \(attributedContent)")
|
||||
}
|
||||
|
||||
for attachment in inputItem.attachments ?? [] {
|
||||
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
|
||||
@ -77,6 +53,12 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
if let url = url as? URL {
|
||||
self?.url = url.absoluteString
|
||||
self?.logger.info("Extracted URL from shared content: \(url.absoluteString)")
|
||||
|
||||
// Set title if we extracted one and current title is empty
|
||||
if let title = extractedTitle, self?.title.isEmpty == true {
|
||||
self?.title = title
|
||||
self?.logger.info("Set title from shared content: \(title)")
|
||||
}
|
||||
} else if let error = error {
|
||||
self?.logger.error("Failed to extract URL: \(error.localizedDescription)")
|
||||
}
|
||||
@ -86,9 +68,18 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let text = text as? String, let url = URL(string: text) {
|
||||
self?.url = url.absoluteString
|
||||
self?.logger.info("Extracted URL from shared text: \(url.absoluteString)")
|
||||
if let text = text as? String {
|
||||
// Only treat as URL if it's a valid URL and we don't have one yet
|
||||
if self?.url == nil, let url = URL(string: text), url.scheme != nil {
|
||||
self?.url = url.absoluteString
|
||||
self?.logger.info("Extracted URL from shared text: \(url.absoluteString)")
|
||||
} else {
|
||||
// If not a valid URL or we already have a URL, treat as potential title
|
||||
if self?.title.isEmpty == true {
|
||||
self?.title = text
|
||||
self?.logger.info("Set title from shared text: \(text)")
|
||||
}
|
||||
}
|
||||
} else if let error = error {
|
||||
self?.logger.error("Failed to extract text: \(error.localizedDescription)")
|
||||
}
|
||||
@ -98,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() {
|
||||
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
|
||||
guard let url = url, !url.isEmpty else {
|
||||
@ -140,14 +99,14 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
}
|
||||
isSaving = true
|
||||
logger.debug("Set saving state to true")
|
||||
|
||||
|
||||
// Check server connectivity
|
||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
||||
logger.debug("Server connectivity for save: \(serverReachable)")
|
||||
if serverReachable {
|
||||
// Online - try to save via API
|
||||
logger.info("Attempting to save bookmark via API")
|
||||
Task {
|
||||
Task {
|
||||
let serverReachable = await serverCheck.checkServerReachability()
|
||||
logger.debug("Server connectivity for save: \(serverReachable)")
|
||||
if serverReachable {
|
||||
// Online - try to save via API
|
||||
logger.info("Attempting to save bookmark via API")
|
||||
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?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||
@ -161,40 +120,67 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
self?.logger.error("Failed to save bookmark via API: \(message)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Server not reachable - save locally
|
||||
logger.info("Server not reachable, attempting local save")
|
||||
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
||||
url: url,
|
||||
title: title,
|
||||
tags: Array(selectedLabels)
|
||||
)
|
||||
logger.info("Local save result: \(success)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isSaving = false
|
||||
} else {
|
||||
// Server not reachable - save locally
|
||||
logger.info("Server not reachable, attempting local save")
|
||||
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
||||
url: url,
|
||||
title: title,
|
||||
tags: Array(selectedLabels)
|
||||
)
|
||||
logger.info("Local save result: \(success)")
|
||||
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
if success {
|
||||
self.logger.info("Bookmark saved locally successfully")
|
||||
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
||||
} else {
|
||||
self.logger.error("Failed to save bookmark locally")
|
||||
self.statusMessage = ("Failed to save locally.", true, "❌")
|
||||
}
|
||||
}
|
||||
|
||||
if success {
|
||||
self.logger.info("Bookmark saved locally successfully")
|
||||
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
await MainActor.run {
|
||||
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() {
|
||||
logger.debug("Completing extension request")
|
||||
guard let context = extensionContext else {
|
||||
logger.warning("Extension context not available for completion")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
context.completeRequest(returningItems: []) { [weak self] error in
|
||||
if 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
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private var hostingController: UIHostingController<ShareBookmarkView>?
|
||||
|
||||
|
||||
private var hostingController: UIHostingController<AnyView>?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext)
|
||||
let swiftUIView = ShareBookmarkView(viewModel: viewModel)
|
||||
let hostingController = UIHostingController(rootView: swiftUIView)
|
||||
.environment(\.managedObjectContext, CoreDataManager.shared.context)
|
||||
let hostingController = UIHostingController(rootView: AnyView(swiftUIView))
|
||||
addChild(hostingController)
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(hostingController.view)
|
||||
@ -34,7 +35,7 @@ class ShareViewController: UIViewController {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(dismissKeyboard),
|
||||
name: NSNotification.Name("DismissKeyboard"),
|
||||
name: .dismissKeyboard,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,40 @@ import Foundation
|
||||
|
||||
class SimpleAPI {
|
||||
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
|
||||
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
|
||||
logger.info("Adding bookmark: \(url)")
|
||||
@ -39,6 +72,11 @@ class SimpleAPI {
|
||||
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
if httpResponse.statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||
}
|
||||
}
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||
@ -87,6 +125,11 @@ class SimpleAPI {
|
||||
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
if httpResponse.statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||
}
|
||||
}
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
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 let labels: [String]?
|
||||
public let title: String?
|
||||
@ -33,4 +45,3 @@ public struct BookmarkLabelDto: Codable, Identifiable {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
docs/401.md
Normal file
41
docs/401.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Feature: Persistentes Logout bei 401 Unauthorized
|
||||
|
||||
## Problemstellung
|
||||
Wenn eine API-Anfrage mit einem `401 Unauthorized`-Response fehlschlägt, bedeutet dies, dass der aktuell gespeicherte Token oder die Session des Nutzers ungültig ist (z. B. durch manuelles Löschen des Tokens im Backend oder andere Ursachen).
|
||||
In diesem Zustand darf der User nicht weiter mit einer scheinbar gültigen Session in der App interagieren.
|
||||
|
||||
## Ziel
|
||||
Die App soll den Nutzer in einem solchen Fall automatisch vollständig **ausloggen** und auf den **Setup-/Login-Screen** umleiten.
|
||||
Dies muss **persistiert** sein, d. h. auch nach App-Neustart darf der Nutzer nicht eingeloggt zurückkehren, solange keine erfolgreiche neue Anmeldung durchgeführt wurde.
|
||||
|
||||
---
|
||||
|
||||
## Anforderungen
|
||||
|
||||
1. **Erkennen von ungültigem Token**
|
||||
- Jede API-Antwort mit `401 Unauthorized` löst den Logout-Prozess aus.
|
||||
- Optional: Kontext beachten (z. B. ob der Request ein Refresh-Token war).
|
||||
|
||||
2. **Logout-Mechanismus**
|
||||
- Alle gespeicherten Zugangsdaten (Access Token, Refresh Token, User-Daten im Keychain/Storage) werden gelöscht.
|
||||
- UI-State wird in den "nicht eingeloggten" Zustand zurückversetzt.
|
||||
- Persistenter "loggedOut"-State wird gesetzt (z. B. in `UserDefaults` oder einer App-State-DB).
|
||||
|
||||
3. **Persistenz**
|
||||
- Falls der Nutzer die App neu startet, startet er im Setup-/Login-Screen und nicht in einem alten Session-Kontext.
|
||||
|
||||
4. **Wiederanmeldung**
|
||||
- Sobald der Nutzer sich neu einloggt und erfolgreich ein Access Token erhält:
|
||||
- wird der persistente "loggedOut"-State zurückgesetzt
|
||||
- die App verhält sich wieder wie gewohnt im eingeloggten Zustand.
|
||||
|
||||
---
|
||||
|
||||
## Beispiel-Use Case
|
||||
- User ist eingeloggt in die App.
|
||||
- Im Backend wird manuell der Token gelöscht oder die Session invalidiert.
|
||||
- Nächster API-Call → API gibt `401 Unauthorized` zurück.
|
||||
- App erkennt ungültigen Zustand, **löscht alle Tokens und Session-Daten** und leitet den User sofort auf den **Setup-/Login-Screen** um.
|
||||
- Auch nach einem App-Neustart startet der User weiterhin im Setup-Screen.
|
||||
- Sobald der User sich erfolgreich einloggt, gelten alle API-Calls wieder normal.
|
||||
|
||||
@ -15,9 +15,9 @@
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
UI["UI Layer\n(View, ViewModel)"]
|
||||
Domain["Domain Layer\n(Use Cases, Models, Repository Protocols)"]
|
||||
Data["Data Layer\n(Repository implementations, Database, Entities, API)"]
|
||||
UI["UI Layer (View, ViewModel)"]
|
||||
Domain["Domain Layer (Use Cases, Models, Repository Protocols)"]
|
||||
Data["Data Layer (Repository implementations, Database, Entities, API)"]
|
||||
UI --> Domain
|
||||
Domain --> Data
|
||||
```
|
||||
283
docs/Tag-Sync-Review.md
Normal file
283
docs/Tag-Sync-Review.md
Normal file
@ -0,0 +1,283 @@
|
||||
# Code Review - Tag Management Refactoring
|
||||
|
||||
**Commit**: ec5706c - Refactor tag management to use Core Data with configurable sorting
|
||||
**Date**: 2025-11-08
|
||||
**Files Changed**: 31 files (+747, -264)
|
||||
|
||||
## Overview
|
||||
|
||||
This review covers a comprehensive refactoring of the tag management system, migrating from API-based tag loading to a Core Data-first approach with background synchronization.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Strengths
|
||||
|
||||
### Architecture & Design
|
||||
|
||||
1. **Clean Architecture Compliance**
|
||||
- New `SyncTagsUseCase` properly separates concerns
|
||||
- ViewModels now only interact with UseCases, not Repositories
|
||||
- Proper dependency injection through UseCaseFactory
|
||||
|
||||
2. **Performance Improvements**
|
||||
- Cache-first strategy provides instant UI response
|
||||
- Background sync eliminates UI blocking
|
||||
- Reduced server load through local caching
|
||||
- SwiftUI `@FetchRequest` provides automatic reactive updates
|
||||
|
||||
3. **Offline Support**
|
||||
- Tags work completely offline using Core Data
|
||||
- Share Extension uses cached tags (no network required)
|
||||
- Graceful degradation when server is unreachable
|
||||
|
||||
4. **User Experience**
|
||||
- Configurable sorting (by count/alphabetically)
|
||||
- Clear sorting indicators in UI
|
||||
- Proper localization (EN/DE)
|
||||
- "Most used tags" in Share Extension for quick access
|
||||
|
||||
### Code Quality
|
||||
|
||||
1. **Consistency**
|
||||
- Consistent use of `@MainActor` for UI updates
|
||||
- Proper async/await patterns throughout
|
||||
- Clear naming conventions
|
||||
|
||||
2. **Documentation**
|
||||
- Comprehensive commit message
|
||||
- Inline documentation for complex logic
|
||||
- `Tags-Sync.md` documentation created
|
||||
|
||||
3. **Testing Support**
|
||||
- Mock implementations added for all new UseCases
|
||||
- Testable architecture with clear boundaries
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Issues & Concerns
|
||||
|
||||
### Critical
|
||||
|
||||
None identified.
|
||||
|
||||
### Major
|
||||
|
||||
1. **LabelsRepository Duplication** (Priority: HIGH)
|
||||
- `LabelsRepository` is instantiated multiple times in different factories
|
||||
- Not using lazy singleton pattern
|
||||
- Could lead to multiple concurrent API calls
|
||||
|
||||
**Location**:
|
||||
- `DefaultUseCaseFactory.makeGetLabelsUseCase()` - line 101
|
||||
- `DefaultUseCaseFactory.makeSyncTagsUseCase()` - line 107
|
||||
|
||||
**Impact**: Inefficient, potential race conditions
|
||||
|
||||
2. **Missing Error Handling** (Priority: MEDIUM)
|
||||
- `syncTags()` silently swallows all errors with `try?`
|
||||
- No user feedback if sync fails
|
||||
- No retry mechanism
|
||||
|
||||
**Locations**:
|
||||
- `AddBookmarkViewModel.syncTags()` - line 69
|
||||
- `BookmarkLabelsViewModel.syncTags()` - line 45
|
||||
|
||||
3. **Legacy Code Not Fully Removed** (Priority: LOW)
|
||||
- `AddBookmarkViewModel.loadAllLabels()` still exists but unused
|
||||
- `BookmarkLabelsViewModel.allLabels` property unused
|
||||
- `LegacyTagManagementView` marked deprecated but not removed
|
||||
|
||||
**Impact**: Code bloat, confusion for future developers
|
||||
|
||||
### Minor
|
||||
|
||||
1. **Hardcoded Values**
|
||||
- Share Extension: `fetchLimit: 150` hardcoded in view
|
||||
- Should be a constant
|
||||
|
||||
**Location**: `ShareBookmarkView.swift:143`
|
||||
|
||||
2. **Inconsistent Localization Approach**
|
||||
- Share Extension uses `"Most used tags"` directly in code
|
||||
- Should use `.localized` extension like main app
|
||||
|
||||
**Location**: `ShareBookmarkView.swift:145`
|
||||
|
||||
3. **Missing Documentation**
|
||||
- `CoreDataTagManagementView` has no class-level documentation
|
||||
- Complex `@FetchRequest` initialization not explained
|
||||
|
||||
**Location**: `CoreDataTagManagementView.swift:4`
|
||||
|
||||
4. **Code Duplication**
|
||||
- Tag sync logic duplicated in `GetLabelsUseCase` and `SyncTagsUseCase`
|
||||
- Both just call `labelsRepository.getLabels()`
|
||||
|
||||
**Locations**:
|
||||
- `GetLabelsUseCase.execute()` - line 14
|
||||
- `SyncTagsUseCase.execute()` - line 19
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Specific File Reviews
|
||||
|
||||
### ShareBookmarkViewModel.swift
|
||||
**Status**: ✅ Good
|
||||
**Changes**: Removed 92 lines of label fetching logic
|
||||
|
||||
- ✅ Properly simplified by removing API logic
|
||||
- ✅ Uses Core Data via `addCustomTag()` helper
|
||||
- ✅ Clean separation of concerns
|
||||
- ⚠️ Could add logging for Core Data fetch failures
|
||||
|
||||
### CoreDataTagManagementView.swift
|
||||
**Status**: ✅ Good
|
||||
**Changes**: New file, 255 lines
|
||||
|
||||
- ✅ Well-structured with clear sections
|
||||
- ✅ Proper use of `@FetchRequest`
|
||||
- ✅ Flexible with optional parameters
|
||||
- ⚠️ Needs class/struct documentation
|
||||
- ⚠️ `availableTagsTitle` parameter could be better named (`customSectionTitle`?)
|
||||
|
||||
### SyncTagsUseCase.swift
|
||||
**Status**: ⚠️ Needs Improvement
|
||||
**Changes**: New file, 21 lines
|
||||
|
||||
- ✅ Follows UseCase pattern correctly
|
||||
- ✅ Good documentation comment
|
||||
- ⚠️ Essentially duplicates `GetLabelsUseCase`
|
||||
- 💡 Could be merged or one could wrap the other
|
||||
|
||||
### LabelsRepository.swift
|
||||
**Status**: ✅ Excellent
|
||||
**Changes**: Enhanced with batch updates and conflict detection
|
||||
|
||||
- ✅ Excellent cache-first + background sync implementation
|
||||
- ✅ Proper batch operations
|
||||
- ✅ Silent failure handling
|
||||
- ✅ Efficient Core Data updates (only saves if changed)
|
||||
|
||||
### AddBookmarkView.swift
|
||||
**Status**: ✅ Good
|
||||
**Changes**: Migrated to CoreDataTagManagementView
|
||||
|
||||
- ✅ Clean migration from old TagManagementView
|
||||
- ✅ Proper use of AppSettings for sort order
|
||||
- ✅ Clear UI with sort indicator
|
||||
- ⚠️ `.onAppear` and `.task` mixing removed - good!
|
||||
|
||||
### Settings Integration
|
||||
**Status**: ✅ Excellent
|
||||
**Changes**: New TagSortOrder setting with persistence
|
||||
|
||||
- ✅ Clean domain model separation
|
||||
- ✅ Proper persistence in SettingsRepository
|
||||
- ✅ Good integration with AppSettings
|
||||
- ✅ UI properly reflects settings changes
|
||||
|
||||
---
|
||||
|
||||
## 📋 TODO List - Improvements
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] **Refactor LabelsRepository instantiation**
|
||||
- Create lazy singleton in DefaultUseCaseFactory
|
||||
- Reuse same instance for GetLabelsUseCase and SyncTagsUseCase
|
||||
- Add comment explaining why singleton is safe here
|
||||
|
||||
- [ ] **Add error handling to sync operations**
|
||||
- Log errors instead of silently swallowing
|
||||
- Consider adding retry logic with exponential backoff
|
||||
- Optional: Show subtle indicator when sync fails
|
||||
|
||||
- [ ] **Remove unused legacy code**
|
||||
- Delete `AddBookmarkViewModel.loadAllLabels()`
|
||||
- Delete `BookmarkLabelsViewModel.allLabels` property
|
||||
- Remove `LegacyTagManagementView.swift` entirely (currently just deprecated)
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] **Extract constants**
|
||||
- Create `Constants.Tags.maxShareExtensionTags = 150`
|
||||
- Create `Constants.Tags.fetchBatchSize = 20`
|
||||
- Reference in CoreDataTagManagementView and ShareBookmarkView
|
||||
|
||||
- [ ] **Improve localization consistency**
|
||||
- Use `.localized` extension in ShareBookmarkView
|
||||
- Ensure all user-facing strings are localized
|
||||
|
||||
- [ ] **Add documentation**
|
||||
- Document `CoreDataTagManagementView` with usage examples
|
||||
- Explain `@FetchRequest` initialization pattern
|
||||
- Add example of how to use `availableTagsTitle` parameter
|
||||
|
||||
### Low Priority
|
||||
|
||||
- [ ] **Consolidate UseCases**
|
||||
- Consider if `SyncTagsUseCase` is necessary
|
||||
- Option 1: Make `GetLabelsUseCase` have a `syncOnly` parameter
|
||||
- Option 2: Have `SyncTagsUseCase` wrap `GetLabelsUseCase`
|
||||
- Document decision either way
|
||||
|
||||
- [ ] **Add unit tests**
|
||||
- Test `SyncTagsUseCase` with mock repository
|
||||
- Test `CoreDataTagManagementView` sort order changes
|
||||
- Test tag sync triggers in ViewModels
|
||||
|
||||
- [ ] **Performance monitoring**
|
||||
- Add metrics for tag sync duration
|
||||
- Track cache hit rate
|
||||
- Monitor Core Data batch operation performance
|
||||
|
||||
- [ ] **Improve parameter naming**
|
||||
- Rename `availableTagsTitle` to `customSectionTitle` or `sectionHeaderTitle`
|
||||
- More descriptive than "available tags"
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Summary
|
||||
|
||||
### Overall Assessment: ✅ **EXCELLENT**
|
||||
|
||||
This refactoring successfully achieves its goals:
|
||||
- ✅ Improved performance through caching
|
||||
- ✅ Better offline support
|
||||
- ✅ Cleaner architecture
|
||||
- ✅ Enhanced user experience
|
||||
|
||||
### Risk Level: **LOW**
|
||||
|
||||
The changes are well-structured and follow established patterns. The main risks are:
|
||||
1. Repository instantiation inefficiency (easily fixed)
|
||||
2. Silent error handling (minor, can be improved later)
|
||||
|
||||
### Recommendation: **APPROVE with minor follow-ups**
|
||||
|
||||
The code is production-ready. The identified improvements are optimizations and cleanups that can be addressed in follow-up commits without blocking deployment.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Metrics
|
||||
|
||||
- **Lines Added**: 747
|
||||
- **Lines Removed**: 264
|
||||
- **Net Change**: +483 lines
|
||||
- **Files Modified**: 31
|
||||
- **New Files**: 7
|
||||
- **Deleted Files**: 0 (1 renamed)
|
||||
- **Test Coverage**: Mocks added ✅
|
||||
|
||||
---
|
||||
|
||||
## 🏆 Best Practices Demonstrated
|
||||
|
||||
1. ✅ Clean Architecture principles
|
||||
2. ✅ SOLID principles (especially Single Responsibility)
|
||||
3. ✅ Proper async/await usage
|
||||
4. ✅ SwiftUI best practices (@FetchRequest, @Published)
|
||||
5. ✅ Comprehensive localization
|
||||
6. ✅ Backwards compatibility (deprecated instead of deleted)
|
||||
7. ✅ Documentation and commit hygiene
|
||||
8. ✅ Testability through dependency injection
|
||||
203
docs/Tags-Sync.md
Normal file
203
docs/Tags-Sync.md
Normal file
@ -0,0 +1,203 @@
|
||||
# Tags Synchronization
|
||||
|
||||
This document describes how tags (labels) are synchronized and updated throughout the readeck app.
|
||||
|
||||
## Overview
|
||||
|
||||
The app uses a **cache-first strategy** with background synchronization to ensure fast UI responses while keeping data up-to-date with the server.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **Core Data Storage** (`TagEntity`)
|
||||
- Local persistent storage for all tags
|
||||
- Fields: `name` (String), `count` (Int32)
|
||||
- Used as the single source of truth for all UI components
|
||||
|
||||
2. **LabelsRepository**
|
||||
- Manages tag synchronization between API and Core Data
|
||||
- Implements cache-first loading strategy
|
||||
|
||||
3. **CoreDataTagManagementView**
|
||||
- SwiftUI view component for tag management
|
||||
- Uses `@FetchRequest` to directly query Core Data
|
||||
- Automatically updates when Core Data changes
|
||||
|
||||
4. **LabelsView**
|
||||
- Full-screen tag list view
|
||||
- Accessible via "More" → "Tags" tab
|
||||
- Triggers manual tag synchronization
|
||||
|
||||
## Synchronization Flow
|
||||
|
||||
### When Tags are Fetched
|
||||
|
||||
Tags are synchronized in the following scenarios:
|
||||
|
||||
#### 1. Opening the Tags Tab
|
||||
**Trigger**: User navigates to "More" → "Tags"
|
||||
**Location**: `LabelsView.swift:43-46`
|
||||
|
||||
```swift
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.loadLabels()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Process**:
|
||||
1. Immediately loads tags from Core Data (instant response)
|
||||
2. Starts background API call to fetch latest tags
|
||||
3. Updates Core Data if API call succeeds
|
||||
4. Silently fails if server is unreachable (keeps cached data)
|
||||
|
||||
#### 2. Background Sync Strategy
|
||||
**Implementation**: `LabelsRepository.getLabels()`
|
||||
|
||||
The repository uses a two-phase approach:
|
||||
|
||||
**Phase 1: Instant Response**
|
||||
```swift
|
||||
let cachedLabels = try await loadLabelsFromCoreData()
|
||||
```
|
||||
- Returns immediately with cached data
|
||||
- Ensures UI is never blocked
|
||||
|
||||
**Phase 2: Background Update**
|
||||
```swift
|
||||
Task.detached(priority: .background) {
|
||||
let dtos = try await self.api.getBookmarkLabels()
|
||||
try? await self.saveLabels(dtos)
|
||||
}
|
||||
```
|
||||
- Runs asynchronously in background
|
||||
- Updates Core Data with latest server data
|
||||
- Silent failure - no error shown to user if sync fails
|
||||
|
||||
#### 3. Adding a New Bookmark
|
||||
**Trigger**: User opens "Add Bookmark" sheet
|
||||
**Location**: `AddBookmarkView.swift:61-66`
|
||||
|
||||
```swift
|
||||
.onAppear {
|
||||
viewModel.checkClipboard()
|
||||
Task {
|
||||
await viewModel.syncTags()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Process**:
|
||||
1. Triggers background sync when view appears
|
||||
2. `CoreDataTagManagementView` shows cached tags immediately
|
||||
3. View automatically updates via `@FetchRequest` when sync completes
|
||||
|
||||
#### 4. Editing Bookmark Labels
|
||||
**Trigger**: User opens "Manage Labels" sheet from bookmark detail
|
||||
**Location**: `BookmarkLabelsView.swift:49-53`
|
||||
|
||||
```swift
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.syncTags()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Process**:
|
||||
1. Triggers background sync when view appears
|
||||
2. `CoreDataTagManagementView` shows cached tags immediately
|
||||
3. View automatically updates via `@FetchRequest` when sync completes
|
||||
|
||||
#### 5. Share Extension
|
||||
|
||||
Tags are **not** synced in the Share Extension:
|
||||
- Uses cached tags from Core Data only
|
||||
- No API calls to minimize extension launch time
|
||||
- Relies on tags synced by main app
|
||||
|
||||
**Reason**: Share Extensions should be fast and lightweight. Tags are already synchronized by the main app when opening tags tab or managing bookmark labels.
|
||||
|
||||
### When Core Data Updates
|
||||
|
||||
Core Data tag updates trigger automatic UI refreshes in all views using `@FetchRequest`:
|
||||
- `CoreDataTagManagementView`
|
||||
- `LabelsView`
|
||||
|
||||
This happens when:
|
||||
- Background sync completes successfully
|
||||
- New tags are created via bookmark operations
|
||||
- Tag counts change due to bookmark label modifications
|
||||
|
||||
## Tag Display Configuration
|
||||
|
||||
### Share Extension
|
||||
- **Fixed sorting**: Always by usage count (`.byCount`)
|
||||
- **Display limit**: Top 150 tags
|
||||
- **Label**: "Most used tags"
|
||||
- **Rationale**: Quick access to most frequently used tags for fast bookmark creation
|
||||
|
||||
### Main App
|
||||
- **User-configurable sorting**: Either by usage count or alphabetically
|
||||
- **Display limit**: All tags (no limit)
|
||||
- **Setting location**: Settings → Appearance → Tag Sort Order
|
||||
- **Labels**:
|
||||
- "Sorted by usage count" (when `.byCount`)
|
||||
- "Sorted alphabetically" (when `.alphabetically`)
|
||||
|
||||
## Data Persistence
|
||||
|
||||
### Core Data Updates
|
||||
Tags in Core Data are updated through:
|
||||
|
||||
1. **Batch sync** (`LabelsRepository.saveLabels`)
|
||||
- Compares existing tags with new data from server
|
||||
- Updates counts for existing tags
|
||||
- Inserts new tags
|
||||
- Only saves if changes detected
|
||||
|
||||
2. **Efficiency optimizations**:
|
||||
- Batch fetch of existing entities
|
||||
- Dictionary-based lookups for fast comparison
|
||||
- Conditional saves to minimize disk I/O
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Network Failures
|
||||
- **Behavior**: Silent failure
|
||||
- **User Experience**: App continues to work with cached data
|
||||
- **Rationale**: Tags are not critical for app functionality; offline access is prioritized
|
||||
|
||||
### Core Data Errors
|
||||
- **Read errors**: UI shows empty state or cached data
|
||||
- **Write errors**: Logged but do not block UI operations
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Deprecated Components
|
||||
- `LegacyTagManagementView.swift`: Old API-based tag management (marked for removal)
|
||||
- `TagManagementView.swift`: Deleted, replaced by `CoreDataTagManagementView.swift`
|
||||
|
||||
### Key Differences: New vs Old Approach
|
||||
|
||||
**Old (LegacyTagManagementView)**:
|
||||
- Fetched tags from API on every view appearance
|
||||
- Slower initial load
|
||||
- Required network connectivity
|
||||
- More server load
|
||||
|
||||
**New (CoreDataTagManagementView)**:
|
||||
- Uses Core Data with `@FetchRequest`
|
||||
- Instant UI response
|
||||
- Works offline
|
||||
- Automatic UI updates via SwiftUI reactivity
|
||||
- Reduced server load through background sync
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Offline tag creation**: Currently, new tags can be created offline but won't sync until server is reachable
|
||||
2. **Tag deletion**: Not implemented in current version
|
||||
3. **Tag renaming**: Not implemented in current version
|
||||
4. **Conflict resolution**: Tags created offline with same name as server tags will merge on sync
|
||||
18
docs/tabbar.md
Normal file
18
docs/tabbar.md
Normal file
@ -0,0 +1,18 @@
|
||||
## Feature: TabView nur in Root-Screens sichtbar, nicht in Detail-Views
|
||||
|
||||
### Beschreibung
|
||||
Die App verwendet aktuell eine `TabView` (bzw. einen Tab Controller), die global sichtbar ist.
|
||||
Der Workflow funktioniert grundsätzlich, allerdings wird die `TabView` auch dann angezeigt, wenn ein Benutzer von einem Tab in eine **Detailansicht** (z. B. Artikel-Detail, Item-Detail) navigiert.
|
||||
|
||||
Ziel ist es, dass die `TabView` **nur in den Root-Views** sichtbar ist.
|
||||
Beim Öffnen einer **Detail-Ansicht** soll die `TabView` automatisch ausgeblendet werden, damit dort alternativ eine eigene **Bottom Toolbar** angezeigt werden kann.
|
||||
|
||||
### Akzeptanzkriterien
|
||||
- `TabView` ist standardmäßig in den Haupt-Tabs sichtbar.
|
||||
- Navigiert der Nutzer in eine Detail-Ansicht (z. B. Rom Detail), wird die `TabView` ausgeblendet.
|
||||
- In den Detail-Ansichten kann ein eigener `Toolbar` oder eine Custom Bottom Bar sichtbar sein - ist aber kein teil von diesem task.
|
||||
- Navigation zurück zur Root-View blendet die `TabView` wieder ein.
|
||||
|
||||
# Technischer hinweis
|
||||
|
||||
To hide TabBar when we jumps towards next screen we just have to place NavigationView to the right place. Makesure Embed TabView inside NavigationView so creating unique Navigationview for both tabs.
|
||||
@ -9,9 +9,9 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; };
|
||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
||||
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
||||
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -67,7 +67,6 @@
|
||||
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -83,16 +82,30 @@
|
||||
membershipExceptions = (
|
||||
Assets.xcassets,
|
||||
Data/CoreData/CoreDataManager.swift,
|
||||
"Data/Extensions/NSManagedObjectContext+SafeFetch.swift",
|
||||
Data/KeychainHelper.swift,
|
||||
Data/Utils/LabelUtils.swift,
|
||||
Domain/Model/Bookmark.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,
|
||||
Splash.storyboard,
|
||||
UI/Components/Constants.swift,
|
||||
UI/Components/CoreDataTagManagementView.swift,
|
||||
UI/Components/CustomTextFieldStyle.swift,
|
||||
UI/Components/TagManagementView.swift,
|
||||
UI/Components/LegacyTagManagementView.swift,
|
||||
UI/Components/UnifiedLabelChip.swift,
|
||||
UI/Extension/FontSizeExtension.swift,
|
||||
UI/Models/AppSettings.swift,
|
||||
UI/Utils/NotificationNames.swift,
|
||||
Utils/Logger.swift,
|
||||
Utils/LogStore.swift,
|
||||
);
|
||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||
};
|
||||
@ -147,8 +160,10 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -172,7 +187,6 @@
|
||||
5D45F9BF2DF858680048D5B8 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
|
||||
5D45F9CA2DF858680048D5B8 /* readeck */,
|
||||
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
|
||||
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
|
||||
@ -240,6 +254,8 @@
|
||||
packageProductDependencies = (
|
||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||
5D9D95482E623668009AF769 /* Kingfisher */,
|
||||
5D48E6012EB402F50043F90F /* MarkdownUI */,
|
||||
);
|
||||
productName = readeck;
|
||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||
@ -330,6 +346,8 @@
|
||||
packageReferences = (
|
||||
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
||||
@ -349,7 +367,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -357,7 +374,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -436,7 +452,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -449,7 +465,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@ -469,7 +485,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -482,7 +498,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@ -624,7 +640,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -647,7 +663,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -668,7 +684,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -691,7 +707,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -853,6 +869,22 @@
|
||||
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" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 8.5.0;
|
||||
};
|
||||
};
|
||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
|
||||
@ -869,6 +901,16 @@
|
||||
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
||||
productName = netfox;
|
||||
};
|
||||
5D48E6012EB402F50043F90F /* MarkdownUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||
productName = MarkdownUI;
|
||||
};
|
||||
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
productName = Kingfisher;
|
||||
};
|
||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
{
|
||||
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
|
||||
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/onevcat/Kingfisher.git",
|
||||
"state" : {
|
||||
"revision" : "2015fda791daa72c8058619545a593bf8c1dd59f",
|
||||
"version" : "8.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "netfox",
|
||||
"kind" : "remoteSourceControl",
|
||||
@ -10,6 +19,15 @@
|
||||
"version" : "1.21.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "r.swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
@ -28,6 +46,24 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@ -2,16 +2,7 @@
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@ -18,6 +18,9 @@ protocol PAPI {
|
||||
func deleteBookmark(id: String) async throws
|
||||
func searchBookmarks(search: String) async throws -> BookmarksPageDto
|
||||
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 {
|
||||
@ -41,6 +44,14 @@ class API: PAPI {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private func handleUnauthorizedResponse(_ statusCode: Int) {
|
||||
if statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeJSONRequestWithHeaders<T: Codable>(
|
||||
endpoint: String,
|
||||
@ -74,6 +85,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -114,6 +126,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -146,6 +159,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -181,6 +195,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
@ -229,7 +244,9 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
if let tag {
|
||||
queryItems.append(URLQueryItem(name: "labels", value: tag))
|
||||
// URL-encode label with quotes for proper API handling
|
||||
let encodedTag = "\"\(tag)\""
|
||||
queryItems.append(URLQueryItem(name: "labels", value: encodedTag))
|
||||
}
|
||||
|
||||
if !queryItems.isEmpty {
|
||||
@ -342,6 +359,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
@ -379,6 +397,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@ -419,15 +438,93 @@ class API: PAPI {
|
||||
logger.debug("Fetching bookmark labels")
|
||||
let endpoint = "/api/bookmarks/labels"
|
||||
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||
|
||||
|
||||
let result = try await makeJSONRequest(
|
||||
endpoint: endpoint,
|
||||
responseType: [BookmarkLabelDto].self
|
||||
)
|
||||
|
||||
|
||||
logger.info("Successfully fetched \(result.count) bookmark labels")
|
||||
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 {
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// Configure viewContext for better extension support
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
|
||||
return container
|
||||
}()
|
||||
|
||||
@ -50,6 +55,16 @@ class CoreDataManager {
|
||||
return persistentContainer.viewContext
|
||||
}
|
||||
|
||||
var mainContext: NSManagedObjectContext {
|
||||
return persistentContainer.viewContext
|
||||
}
|
||||
|
||||
func newBackgroundContext() -> NSManagedObjectContext {
|
||||
let context = persistentContainer.newBackgroundContext()
|
||||
context.automaticallyMergesChangesFromParent = true
|
||||
return context
|
||||
}
|
||||
|
||||
func save() {
|
||||
if context.hasChanges {
|
||||
do {
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
//
|
||||
// NSManagedObjectContext+SafeFetch.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 25.07.25.
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// This file is part of the readeck project and is licensed under the MIT License.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
|
||||
/// Thread-safe fetch that automatically wraps the operation in performAndWait
|
||||
func safeFetch<T: NSManagedObject>(_ request: NSFetchRequest<T>) throws -> [T] {
|
||||
var results: [T] = []
|
||||
var fetchError: Error?
|
||||
|
||||
performAndWait {
|
||||
do {
|
||||
results = try self.fetch(request)
|
||||
} catch {
|
||||
fetchError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let error = fetchError {
|
||||
throw error
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/// Thread-safe perform operation with return value
|
||||
func safePerform<T>(_ operation: @escaping @Sendable () throws -> T) throws -> T {
|
||||
var result: T?
|
||||
var operationError: Error?
|
||||
|
||||
performAndWait {
|
||||
do {
|
||||
result = try operation()
|
||||
} catch {
|
||||
operationError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let error = operationError {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard let unwrappedResult = result else {
|
||||
throw NSError(domain: "SafePerformError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Operation returned nil"])
|
||||
}
|
||||
|
||||
return unwrappedResult
|
||||
}
|
||||
|
||||
/// Thread-safe perform operation without return value
|
||||
func safePerform(_ operation: @escaping () throws -> Void) throws {
|
||||
var operationError: Error?
|
||||
|
||||
performAndWait {
|
||||
do {
|
||||
try operation()
|
||||
} catch {
|
||||
operationError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let error = operationError {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
13
readeck/Data/Extensions/String+Localization.swift
Normal file
13
readeck/Data/Extensions/String+Localization.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Returns a localized version of the string using NSLocalizedString
|
||||
var localized: String {
|
||||
return NSLocalizedString(self, comment: "")
|
||||
}
|
||||
|
||||
/// Returns a localized version of the string with comment
|
||||
func localized(comment: String) -> String {
|
||||
return NSLocalizedString(self, comment: comment)
|
||||
}
|
||||
}
|
||||
@ -9,11 +9,12 @@ import Foundation
|
||||
import CoreData
|
||||
|
||||
extension BookmarkLabelDto {
|
||||
|
||||
|
||||
@discardableResult
|
||||
func toEntity(context: NSManagedObjectContext) -> TagEntity {
|
||||
let entity = TagEntity(context: context)
|
||||
entity.name = name
|
||||
entity.count = Int32(count)
|
||||
return entity
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class LabelsRepository: PLabelsRepository {
|
||||
class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
||||
private let api: PAPI
|
||||
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
@ -11,33 +11,107 @@ class LabelsRepository: PLabelsRepository {
|
||||
}
|
||||
|
||||
func getLabels() async throws -> [BookmarkLabel] {
|
||||
let dtos = try await api.getBookmarkLabels()
|
||||
try? await saveLabels(dtos)
|
||||
return dtos.map { $0.toDomain() }
|
||||
// First, load from Core Data (instant response)
|
||||
let cachedLabels = try await loadLabelsFromCoreData()
|
||||
|
||||
// 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 {
|
||||
for dto in dtos {
|
||||
if !tagExists(name: dto.name) {
|
||||
dto.toEntity(context: coreDataManager.context)
|
||||
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||
|
||||
try await backgroundContext.perform {
|
||||
// Batch fetch all existing labels
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.propertiesToFetch = ["name", "count"]
|
||||
|
||||
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 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()
|
||||
}
|
||||
}
|
||||
try coreDataManager.context.save()
|
||||
}
|
||||
|
||||
private func tagExists(name: String) -> Bool {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
|
||||
|
||||
var exists = false
|
||||
coreDataManager.context.performAndWait {
|
||||
do {
|
||||
let results = try coreDataManager.context.fetch(fetchRequest)
|
||||
exists = !results.isEmpty
|
||||
} catch {
|
||||
exists = false
|
||||
|
||||
func saveNewLabel(name: String) async throws {
|
||||
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||
|
||||
try await backgroundContext.perform {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
|
||||
// Check if label already exists
|
||||
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()
|
||||
}
|
||||
}
|
||||
return exists
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,24 +2,27 @@ import Foundation
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
class OfflineSyncManager: ObservableObject {
|
||||
class OfflineSyncManager: ObservableObject, @unchecked Sendable {
|
||||
static let shared = OfflineSyncManager()
|
||||
|
||||
|
||||
@Published var isSyncing = false
|
||||
@Published var syncStatus: String?
|
||||
|
||||
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
private let api: PAPI
|
||||
|
||||
init(api: PAPI = API()) {
|
||||
private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase
|
||||
|
||||
init(api: PAPI = API(),
|
||||
checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) {
|
||||
self.api = api
|
||||
self.checkServerReachabilityUseCase = checkServerReachabilityUseCase
|
||||
}
|
||||
|
||||
// MARK: - Sync Methods
|
||||
|
||||
func syncOfflineBookmarks() async {
|
||||
// First check if server is reachable
|
||||
guard await ServerConnectivity.isServerReachable() else {
|
||||
guard await checkServerReachabilityUseCase.execute() else {
|
||||
await MainActor.run {
|
||||
isSyncing = false
|
||||
syncStatus = "Server not reachable. Cannot sync."
|
||||
@ -99,10 +102,9 @@ class OfflineSyncManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func getOfflineBookmarks() -> [ArticleURLEntity] {
|
||||
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
||||
|
||||
do {
|
||||
return try coreDataManager.context.fetch(fetchRequest)
|
||||
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
||||
return try coreDataManager.context.safeFetch(fetchRequest)
|
||||
} catch {
|
||||
print("Failed to fetch offline bookmarks: \(error)")
|
||||
return []
|
||||
@ -110,26 +112,16 @@ class OfflineSyncManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func deleteOfflineBookmark(_ entity: ArticleURLEntity) {
|
||||
coreDataManager.context.delete(entity)
|
||||
coreDataManager.save()
|
||||
}
|
||||
|
||||
// MARK: - Auto Sync on Server Connectivity Changes
|
||||
|
||||
func startAutoSync() {
|
||||
// Monitor server connectivity and auto-sync when server becomes reachable
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("ServerDidBecomeAvailable"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task {
|
||||
await self?.syncOfflineBookmarks()
|
||||
do {
|
||||
try coreDataManager.context.safePerform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.coreDataManager.context.delete(entity)
|
||||
self.coreDataManager.save()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to delete offline bookmark: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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,27 +1,6 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
struct Settings {
|
||||
var endpoint: String? = nil
|
||||
var username: String? = nil
|
||||
var password: String? = nil
|
||||
var token: String? = nil
|
||||
|
||||
var fontFamily: FontFamily? = nil
|
||||
var fontSize: FontSize? = nil
|
||||
var hasFinishedSetup: Bool = false
|
||||
var enableTTS: Bool? = nil
|
||||
var theme: Theme? = nil
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
token != nil && !token!.isEmpty
|
||||
}
|
||||
|
||||
mutating func setToken(_ newToken: String) {
|
||||
token = newToken
|
||||
}
|
||||
}
|
||||
|
||||
protocol PSettingsRepository {
|
||||
func saveSettings(_ settings: Settings) async throws
|
||||
func loadSettings() async throws -> Settings?
|
||||
@ -30,7 +9,11 @@ protocol PSettingsRepository {
|
||||
func saveUsername(_ username: String) async throws
|
||||
func savePassword(_ password: String) async throws
|
||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
|
||||
func loadCardLayoutStyle() async throws -> CardLayoutStyle
|
||||
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
|
||||
func loadTagSortOrder() async throws -> TagSortOrder
|
||||
var hasFinishedSetup: Bool { get }
|
||||
}
|
||||
|
||||
@ -39,6 +22,15 @@ class SettingsRepository: PSettingsRepository {
|
||||
private let userDefault = UserDefaults.standard
|
||||
private let keychainHelper = KeychainHelper.shared
|
||||
|
||||
var hasFinishedSetup: Bool {
|
||||
get {
|
||||
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
|
||||
}
|
||||
set {
|
||||
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
||||
}
|
||||
}
|
||||
|
||||
func saveSettings(_ settings: Settings) async throws {
|
||||
// Save credentials to keychain
|
||||
if let endpoint = settings.endpoint, !endpoint.isEmpty {
|
||||
@ -79,6 +71,18 @@ class SettingsRepository: PSettingsRepository {
|
||||
existingSettings.theme = theme.rawValue
|
||||
}
|
||||
|
||||
if let urlOpener = settings.urlOpener {
|
||||
existingSettings.urlOpener = urlOpener.rawValue
|
||||
}
|
||||
|
||||
if let cardLayoutStyle = settings.cardLayoutStyle {
|
||||
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||
}
|
||||
|
||||
if let tagSortOrder = settings.tagSortOrder {
|
||||
existingSettings.tagSortOrder = tagSortOrder.rawValue
|
||||
}
|
||||
|
||||
try context.save()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
@ -115,7 +119,10 @@ class SettingsRepository: PSettingsRepository {
|
||||
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
|
||||
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
||||
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),
|
||||
tagSortOrder: TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue),
|
||||
urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue)
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
} catch {
|
||||
@ -160,7 +167,7 @@ class SettingsRepository: PSettingsRepository {
|
||||
self.hasFinishedSetup = true
|
||||
// Notification senden, dass sich der Setup-Status geändert hat
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -174,7 +181,7 @@ class SettingsRepository: PSettingsRepository {
|
||||
if !token.isEmpty {
|
||||
self.hasFinishedSetup = true
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -192,18 +199,91 @@ class SettingsRepository: PSettingsRepository {
|
||||
self.hasFinishedSetup = hasFinishedSetup
|
||||
// Notification senden, dass sich der Setup-Status geändert hat
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
var hasFinishedSetup: Bool {
|
||||
get {
|
||||
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
|
||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) 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.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||
|
||||
try context.save()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
set {
|
||||
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
||||
}
|
||||
|
||||
func loadCardLayoutStyle() async throws -> CardLayoutStyle {
|
||||
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 cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine
|
||||
continuation.resume(returning: cardLayoutStyle)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,19 +10,38 @@ protocol TokenProvider {
|
||||
class KeychainTokenProvider: TokenProvider {
|
||||
private let keychainHelper = KeychainHelper.shared
|
||||
|
||||
// Cache to avoid repeated keychain access
|
||||
private var cachedToken: String?
|
||||
private var cachedEndpoint: String?
|
||||
|
||||
func getToken() async -> String? {
|
||||
return keychainHelper.loadToken()
|
||||
if let cached = cachedToken {
|
||||
return cached
|
||||
}
|
||||
|
||||
let token = keychainHelper.loadToken()
|
||||
cachedToken = token
|
||||
return token
|
||||
}
|
||||
|
||||
func getEndpoint() async -> String? {
|
||||
return keychainHelper.loadEndpoint()
|
||||
if let cached = cachedEndpoint {
|
||||
return cached
|
||||
}
|
||||
|
||||
let endpoint = keychainHelper.loadEndpoint()
|
||||
cachedEndpoint = endpoint
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func setToken(_ token: String) async {
|
||||
keychainHelper.saveToken(token)
|
||||
cachedToken = token
|
||||
}
|
||||
|
||||
func clearToken() async {
|
||||
keychainHelper.clearCredentials()
|
||||
cachedToken = nil
|
||||
cachedEndpoint = nil
|
||||
}
|
||||
}
|
||||
|
||||
27
readeck/Data/Utils/LabelUtils.swift
Normal file
27
readeck/Data/Utils/LabelUtils.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
struct LabelUtils {
|
||||
/// Processes a label input string and returns it as a single trimmed label
|
||||
/// - Parameter input: The input string containing a label (spaces are allowed)
|
||||
/// - Returns: Array containing the trimmed label, or empty array if input is empty
|
||||
static func splitLabelsFromInput(_ input: String) -> [String] {
|
||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? [] : [trimmed]
|
||||
}
|
||||
|
||||
/// Filters out labels that already exist in current or available labels
|
||||
/// - Parameters:
|
||||
/// - labels: Array of labels to filter
|
||||
/// - currentLabels: Currently selected labels
|
||||
/// - availableLabels: Available labels (optional)
|
||||
/// - Returns: Array of unique labels that don't already exist
|
||||
static func filterUniqueLabels(_ labels: [String], currentLabels: [String], availableLabels: [String] = []) -> [String] {
|
||||
let currentSet = Set(currentLabels.map { $0.lowercased() })
|
||||
let availableSet = Set(availableLabels.map { $0.lowercased() })
|
||||
|
||||
return labels.filter { label in
|
||||
let lowercased = label.lowercased()
|
||||
return !currentSet.contains(lowercased) && !availableSet.contains(lowercased)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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: NSNotification.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
|
||||
}
|
||||
}
|
||||
29
readeck/Domain/Model/CardLayoutStyle.swift
Normal file
29
readeck/Domain/Model/CardLayoutStyle.swift
Normal file
@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
enum CardLayoutStyle: String, CaseIterable, Codable {
|
||||
case compact = "compact"
|
||||
case magazine = "magazine"
|
||||
case natural = "natural"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .compact:
|
||||
return "Compact"
|
||||
case .magazine:
|
||||
return "Magazine"
|
||||
case .natural:
|
||||
return "Natural"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .compact:
|
||||
return "Small thumbnails with content focus"
|
||||
case .magazine:
|
||||
return "Fixed height headers for consistent layout"
|
||||
case .natural:
|
||||
return "Images in original aspect ratio"
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
readeck/Domain/Model/UrlOpener.swift
Normal file
18
readeck/Domain/Model/UrlOpener.swift
Normal file
@ -0,0 +1,18 @@
|
||||
//
|
||||
// UrlOpener.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 06.11.25.
|
||||
//
|
||||
|
||||
enum UrlOpener: String, CaseIterable {
|
||||
case inAppBrowser = "inAppBrowser"
|
||||
case defaultBrowser = "defaultBrowser"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .inAppBrowser: return "In App Browser"
|
||||
case .defaultBrowser: return "Default Browser"
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
func getLabels() async throws -> [BookmarkLabel]
|
||||
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/LoadCardLayoutUseCase.swift
Normal file
21
readeck/Domain/UseCase/LoadCardLayoutUseCase.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
protocol PLoadCardLayoutUseCase {
|
||||
func execute() async -> CardLayoutStyle
|
||||
}
|
||||
|
||||
class LoadCardLayoutUseCase: PLoadCardLayoutUseCase {
|
||||
private let settingsRepository: PSettingsRepository
|
||||
|
||||
init(settingsRepository: PSettingsRepository) {
|
||||
self.settingsRepository = settingsRepository
|
||||
}
|
||||
|
||||
func execute() async -> CardLayoutStyle {
|
||||
do {
|
||||
return try await settingsRepository.loadCardLayoutStyle()
|
||||
} catch {
|
||||
return .magazine
|
||||
}
|
||||
}
|
||||
}
|
||||
22
readeck/Domain/UseCase/SaveCardLayoutUseCase.swift
Normal file
22
readeck/Domain/UseCase/SaveCardLayoutUseCase.swift
Normal file
@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
protocol PSaveCardLayoutUseCase {
|
||||
func execute(layout: CardLayoutStyle) async
|
||||
}
|
||||
|
||||
class SaveCardLayoutUseCase: PSaveCardLayoutUseCase {
|
||||
private let settingsRepository: PSettingsRepository
|
||||
private let logger = Logger.data
|
||||
|
||||
init(settingsRepository: PSettingsRepository) {
|
||||
self.settingsRepository = settingsRepository
|
||||
}
|
||||
|
||||
func execute(layout: CardLayoutStyle) async {
|
||||
do {
|
||||
try await settingsRepository.saveCardLayoutStyle(layout)
|
||||
} catch {
|
||||
logger.error("Failed to save card layout style: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ protocol PSaveSettingsUseCase {
|
||||
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
|
||||
func execute(enableTTS: Bool) async throws
|
||||
func execute(theme: Theme) async throws
|
||||
func execute(urlOpener: UrlOpener) async throws
|
||||
}
|
||||
|
||||
class SaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
@ -33,4 +34,10 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
.init(theme: theme)
|
||||
)
|
||||
}
|
||||
|
||||
func execute(urlOpener: UrlOpener) async throws {
|
||||
try await settingsRepository.saveSettings(
|
||||
.init(urlOpener: urlOpener)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
155
readeck/Localizations/Base.lproj/Localizable.strings
Normal file
155
readeck/Localizations/Base.lproj/Localizable.strings
Normal file
@ -0,0 +1,155 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
readeck
|
||||
|
||||
Created by conversion from Localizable.xcstrings
|
||||
*/
|
||||
|
||||
"" = "";
|
||||
"(%lld found)" = "(%lld found)";
|
||||
"%" = "%";
|
||||
"%@ (%lld)" = "%1$@ (%2$lld)";
|
||||
"%lld" = "%lld";
|
||||
"%lld articles in the queue" = "%lld articles in the queue";
|
||||
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
|
||||
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
|
||||
"%lld min" = "%lld min";
|
||||
"%lld." = "%lld.";
|
||||
"%lld/%lld" = "%1$lld/%2$lld";
|
||||
"12 min • Today • example.com" = "12 min • Today • example.com";
|
||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
||||
|
||||
/* Legal & Privacy */
|
||||
"Legal & Privacy" = "Legal & Privacy";
|
||||
"Privacy Policy" = "Privacy Policy";
|
||||
"Legal Notice" = "Legal Notice";
|
||||
"Report an Issue" = "Report an Issue";
|
||||
"Contact Support" = "Contact Support";
|
||||
|
||||
/* Navigation & States */
|
||||
"All" = "All";
|
||||
"Unread" = "Unread";
|
||||
"Favorites" = "Favorites";
|
||||
"Archive" = "Archive";
|
||||
"Search" = "Search";
|
||||
"Settings" = "Settings";
|
||||
"Articles" = "Articles";
|
||||
"Videos" = "Videos";
|
||||
"Pictures" = "Pictures";
|
||||
"Tags" = "Tags";
|
||||
|
||||
/* Settings Sections */
|
||||
"Font Settings" = "Font Settings";
|
||||
"Appearance" = "Appearance";
|
||||
"Cache Settings" = "Cache Settings";
|
||||
"General Settings" = "General Settings";
|
||||
"Server Settings" = "Server Settings";
|
||||
"Server Connection" = "Server Connection";
|
||||
|
||||
"Add" = "Add";
|
||||
"Add new tag:" = "Add new tag:";
|
||||
"all" = "all";
|
||||
"All tags selected" = "All tags selected";
|
||||
"Archive" = "Archive";
|
||||
"Archive bookmark" = "Archive bookmark";
|
||||
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
|
||||
"Available tags" = "Available tags";
|
||||
"Cancel" = "Cancel";
|
||||
"Category-specific Levels" = "Category-specific Levels";
|
||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
|
||||
"Close" = "Close";
|
||||
"Configure log levels and categories" = "Configure log levels and categories";
|
||||
"Critical" = "Critical";
|
||||
"Debug" = "Debug";
|
||||
"DEBUG BUILD" = "DEBUG BUILD";
|
||||
"Debug Settings" = "Debug Settings";
|
||||
"Delete" = "Delete";
|
||||
"Delete Bookmark" = "Delete Bookmark";
|
||||
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
|
||||
"Done" = "Done";
|
||||
"Enter an optional title..." = "Enter an optional title...";
|
||||
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
|
||||
"Error" = "Error";
|
||||
"Error: %@" = "Error: %@";
|
||||
"Favorite" = "Favorite";
|
||||
"Finished reading?" = "Finished reading?";
|
||||
"Font" = "Font";
|
||||
"Font family" = "Font family";
|
||||
"Font Settings" = "Font Settings";
|
||||
"Font size" = "Font size";
|
||||
"From Bremen with 💚" = "From Bremen with 💚";
|
||||
"General" = "General";
|
||||
"Global Level" = "Global Level";
|
||||
"Global Minimum Level" = "Global Minimum Level";
|
||||
"Global Settings" = "Global Settings";
|
||||
"https://example.com" = "https://example.com";
|
||||
"https://readeck.example.com" = "https://readeck.example.com";
|
||||
"Include Source Location" = "Include Source Location";
|
||||
"Info" = "Info";
|
||||
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
|
||||
"Key" = "Key";
|
||||
"Level for %@" = "Level for %@";
|
||||
"Loading %@" = "Loading %@";
|
||||
"Loading article..." = "Loading article...";
|
||||
"Logging Configuration" = "Logging Configuration";
|
||||
"Login & Save" = "Login & Save";
|
||||
"Logout" = "Logout";
|
||||
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
|
||||
"Manage Labels" = "Manage Labels";
|
||||
"Mark as favorite" = "Mark as favorite";
|
||||
"More" = "More";
|
||||
"New Bookmark" = "New Bookmark";
|
||||
"No articles in the queue" = "No articles in the queue";
|
||||
"No bookmarks" = "No bookmarks";
|
||||
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
||||
"No bookmarks found." = "No bookmarks found.";
|
||||
"No results" = "No results";
|
||||
"Notice" = "Notice";
|
||||
"OK" = "OK";
|
||||
"Optional: Custom title" = "Optional: Custom title";
|
||||
"Password" = "Password";
|
||||
"Paste" = "Paste";
|
||||
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
|
||||
"Preview" = "Preview";
|
||||
"Progress: %lld%%" = "Progress: %lld%%";
|
||||
"Re-login & Save" = "Re-login & Save";
|
||||
"Read Aloud Feature" = "Read Aloud Feature";
|
||||
"Read article aloud" = "Read article aloud";
|
||||
"Read-aloud Queue" = "Read-aloud Queue";
|
||||
"readeck Bookmark Title" = "readeck Bookmark Title";
|
||||
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
|
||||
"Remove" = "Remove";
|
||||
"Reset" = "Reset";
|
||||
"Reset to Defaults" = "Reset to Defaults";
|
||||
"Restore" = "Restore";
|
||||
"Resume listening" = "Resume listening";
|
||||
"Save bookmark" = "Save bookmark";
|
||||
"Save Bookmark" = "Save Bookmark";
|
||||
"Saving..." = "Saving...";
|
||||
"Search" = "Search";
|
||||
"Search or add new tag..." = "Search or add new tag...";
|
||||
"Search results" = "Search results";
|
||||
"Search..." = "Search...";
|
||||
"Searching..." = "Searching...";
|
||||
"Select a bookmark or tag" = "Select a bookmark or tag";
|
||||
"Selected tags" = "Selected tags";
|
||||
"Server Endpoint" = "Server Endpoint";
|
||||
"Server not reachable - saving locally" = "Server not reachable - saving locally";
|
||||
"Settings" = "Settings";
|
||||
"Show Performance Logs" = "Show Performance Logs";
|
||||
"Show Timestamps" = "Show Timestamps";
|
||||
"Speed" = "Speed";
|
||||
"Syncing with server..." = "Syncing with server...";
|
||||
"Theme" = "Theme";
|
||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
|
||||
"Try Again" = "Try Again";
|
||||
"Unable to load bookmarks" = "Unable to load bookmarks";
|
||||
"Unarchive Bookmark" = "Unarchive Bookmark";
|
||||
"URL in clipboard:" = "URL in clipboard:";
|
||||
"Username" = "Username";
|
||||
"Version %@" = "Version %@";
|
||||
"Warning" = "Warning";
|
||||
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
|
||||
"Your Password" = "Your Password";
|
||||
"Your Username" = "Your Username";
|
||||
165
readeck/Localizations/de.lproj/Localizable.strings
Normal file
165
readeck/Localizations/de.lproj/Localizable.strings
Normal file
@ -0,0 +1,165 @@
|
||||
/*
|
||||
Localizable.strings (German)
|
||||
readeck
|
||||
|
||||
Created by conversion from Localizable.xcstrings
|
||||
*/
|
||||
|
||||
|
||||
"" = "";
|
||||
"(%lld found)" = "(%lld gefunden)";
|
||||
"%" = "%";
|
||||
"%@ (%lld)" = "%1$@ (%2$lld)";
|
||||
"%lld" = "%lld";
|
||||
"%lld articles in the queue" = "%lld Artikel in der Warteschlange";
|
||||
"%lld bookmark%@ synced successfully" = "%1$lld Lesezeichen%2$@ erfolgreich synchronisiert";
|
||||
"%lld bookmark%@ waiting for sync" = "%1$lld Lesezeichen%2$@ warten auf Synchronisation";
|
||||
"%lld min" = "%lld Min";
|
||||
"%lld." = "%lld.";
|
||||
"%lld/%lld" = "%1$lld/%2$lld";
|
||||
"12 min • Today • example.com" = "12 Min • Heute • example.com";
|
||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Aktiviere die Vorlese-Funktion, um deine Artikel vorlesen zu lassen. Dies ist eine sehr frühe Vorschau und funktioniert möglicherweise noch nicht perfekt.";
|
||||
|
||||
/* Legal & Privacy */
|
||||
"Legal & Privacy" = "Rechtliches & Datenschutz";
|
||||
"Privacy Policy" = "Datenschutzerklärung";
|
||||
"Legal Notice" = "Impressum";
|
||||
"Report an Issue" = "Problem melden";
|
||||
"Contact Support" = "Support kontaktieren";
|
||||
|
||||
/* Navigation & States */
|
||||
"All" = "Alle";
|
||||
"Unread" = "Ungelesen";
|
||||
"Favorites" = "Favoriten";
|
||||
"Archive" = "Archiv";
|
||||
"Search" = "Suchen";
|
||||
"Settings" = "Einstellungen";
|
||||
"Articles" = "Artikel";
|
||||
"Videos" = "Videos";
|
||||
"Pictures" = "Bilder";
|
||||
"Tags" = "Labels";
|
||||
|
||||
/* Settings Sections */
|
||||
"Font Settings" = "Schriftart";
|
||||
"Appearance" = "Darstellung";
|
||||
"Cache Settings" = "Cache";
|
||||
"General Settings" = "Allgemein";
|
||||
"Server Settings" = "Server";
|
||||
"Server Connection" = "Server-Verbindung";
|
||||
"Open external links in" = "Öffne externe Links in";
|
||||
"In App Browser" = "In App Browser";
|
||||
"Default Browser" = "Standard Browser";
|
||||
|
||||
"Add" = "Hinzufügen";
|
||||
"Add new tag:" = "Neues Label hinzufügen:";
|
||||
"all" = "alle";
|
||||
"All tags selected" = "Alle Labels ausgewählt";
|
||||
"Archive" = "Archivieren";
|
||||
"Archive bookmark" = "Lesezeichen archivieren";
|
||||
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Dieses Lesezeichen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.";
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Wirklich abmelden? Dies löscht alle Anmeldedaten und führt zurück zur Einrichtung.";
|
||||
"Available tags" = "Verfügbare Labels";
|
||||
"Most used tags" = "Meist verwendete Labels";
|
||||
"Sorted by usage count" = "Sortiert nach Verwendungshäufigkeit";
|
||||
"Sorted alphabetically" = "Alphabetisch sortiert";
|
||||
"Cancel" = "Abbrechen";
|
||||
"Category-specific Levels" = "Kategorie-spezifische Level";
|
||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Änderungen werden sofort wirksam. Niedrigere Log-Level enthalten höhere (Debug enthält alle, Critical nur kritische Nachrichten).";
|
||||
"Close" = "Schließen";
|
||||
"Configure log levels and categories" = "Log-Level und Kategorien konfigurieren";
|
||||
"Critical" = "Kritisch";
|
||||
"Debug" = "Debug";
|
||||
"DEBUG BUILD" = "DEBUG BUILD";
|
||||
"Debug Settings" = "Debug";
|
||||
"Delete" = "Löschen";
|
||||
"Delete Bookmark" = "Lesezeichen löschen";
|
||||
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
|
||||
"Done" = "Fertig";
|
||||
"Enter an optional title..." = "Optionalen Titel eingeben...";
|
||||
"Enter your Readeck server details to get started." = "Readeck-Server-Details eingeben, um zu beginnen.";
|
||||
"Error" = "Fehler";
|
||||
"Error: %@" = "Fehler: %@";
|
||||
"Favorite" = "Favorit";
|
||||
"Finished reading?" = "Fertig gelesen?";
|
||||
"Font" = "Schrift";
|
||||
"Font family" = "Schriftart";
|
||||
"Font Settings" = "Schrift";
|
||||
"Font size" = "Schriftgröße";
|
||||
"From Bremen with 💚" = "Aus Bremen mit 💚";
|
||||
"General" = "Allgemein";
|
||||
"Global Level" = "Globales Level";
|
||||
"Global Minimum Level" = "Globales Minimum-Level";
|
||||
"Global Settings" = "Global";
|
||||
"https://example.com" = "https://example.com";
|
||||
"https://readeck.example.com" = "https://readeck.example.com";
|
||||
"Include Source Location" = "Quellort einschließen";
|
||||
"Info" = "Info";
|
||||
"Jump to last read position (%lld%%)" = "Zur letzten Leseposition springen (%lld%%)";
|
||||
"Key" = "Schlüssel";
|
||||
"Level for %@" = "Level für %@";
|
||||
"Loading %@" = "Lade %@";
|
||||
"Loading article..." = "Artikel wird geladen...";
|
||||
"Logging Configuration" = "Logging-Konfiguration";
|
||||
"Login & Save" = "Anmelden & Speichern";
|
||||
"Logout" = "Abmelden";
|
||||
"Logs below this level will be filtered out globally" = "Logs unter diesem Level werden global herausgefiltert";
|
||||
"Manage Labels" = "Labels verwalten";
|
||||
"Mark as favorite" = "Als Favorit markieren";
|
||||
"More" = "Mehr";
|
||||
"New Bookmark" = "Neues Lesezeichen";
|
||||
"No articles in the queue" = "Keine Artikel in der Warteschlange";
|
||||
"open_url" = "%@ öffnen";
|
||||
"open_original_page" = "Originalseite öffnen";
|
||||
"No bookmarks" = "Keine Lesezeichen";
|
||||
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
|
||||
"No bookmarks found." = "Keine Lesezeichen gefunden.";
|
||||
"No results" = "Keine Ergebnisse";
|
||||
"Notice" = "Hinweis";
|
||||
"OK" = "OK";
|
||||
"Optional: Custom title" = "Optional: Benutzerdefinierter Titel";
|
||||
"Password" = "Passwort";
|
||||
"Paste" = "Einfügen";
|
||||
"Please wait while we fetch your bookmarks..." = "Bitte warten, während die Lesezeichen geladen werden...";
|
||||
"Preview" = "Vorschau";
|
||||
"Progress: %lld%%" = "Fortschritt: %lld%%";
|
||||
"Re-login & Save" = "Erneut anmelden & Speichern";
|
||||
"Read Aloud Feature" = "Vorlese-Funktion";
|
||||
"Read article aloud" = "Artikel vorlesen";
|
||||
"Read-aloud Queue" = "Vorlese-Warteschlange";
|
||||
"readeck Bookmark Title" = "readeck Lesezeichen-Titel";
|
||||
"Reading %lld/%lld: " = "Lese %1$lld/%2$lld: ";
|
||||
"Remove" = "Entfernen";
|
||||
"Reset" = "Zurücksetzen";
|
||||
"Reset to Defaults" = "Auf Standardwerte zurücksetzen";
|
||||
"Restore" = "Wiederherstellen";
|
||||
"Resume listening" = "Zuhören fortsetzen";
|
||||
"Save bookmark" = "Lesezeichen speichern";
|
||||
"Save Bookmark" = "Lesezeichen speichern";
|
||||
"Saving..." = "Speichern...";
|
||||
"Search" = "Suchen";
|
||||
"Search or add new tag..." = "Suchen oder neues Label hinzufügen...";
|
||||
"Search results" = "Suchergebnisse";
|
||||
"Search..." = "Suchen...";
|
||||
"Searching..." = "Suche...";
|
||||
"Select a bookmark or tag" = "Lesezeichen oder Label auswählen";
|
||||
"Selected tags" = "Ausgewählte Labels";
|
||||
"Server Endpoint" = "Server-Endpunkt";
|
||||
"Server not reachable - saving locally" = "Server nicht erreichbar - speichere lokal";
|
||||
"Settings" = "Einstellungen";
|
||||
"Show Performance Logs" = "Performance-Logs anzeigen";
|
||||
"Show Timestamps" = "Zeitstempel anzeigen";
|
||||
"Speed" = "Geschwindigkeit";
|
||||
"Syncing with server..." = "Synchronisiere mit Server...";
|
||||
"Theme" = "Design";
|
||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "So werden Lesezeichen-Beschreibungen und Artikeltexte in der App angezeigt. Franz jagt im komplett verwahrlosten Taxi quer durch Bayern.";
|
||||
"Try Again" = "Erneut versuchen";
|
||||
"Unable to load bookmarks" = "Lesezeichen können nicht geladen werden";
|
||||
"Unarchive Bookmark" = "Lesezeichen aus Archiv entfernen";
|
||||
"URL in clipboard:" = "URL in Zwischenablage:";
|
||||
"Username" = "Benutzername";
|
||||
"Version %@" = "Version %@";
|
||||
"Warning" = "Warnung";
|
||||
"Your current server connection and login credentials." = "Aktuelle Serververbindung und Anmeldedaten.";
|
||||
"Your Password" = "Passwort";
|
||||
"Your Username" = "Benutzername";
|
||||
|
||||
160
readeck/Localizations/en.lproj/Localizable.strings
Normal file
160
readeck/Localizations/en.lproj/Localizable.strings
Normal file
@ -0,0 +1,160 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
readeck
|
||||
|
||||
Created by conversion from Localizable.xcstrings
|
||||
*/
|
||||
|
||||
"" = "";
|
||||
"(%lld found)" = "(%lld found)";
|
||||
"%" = "%";
|
||||
"%@ (%lld)" = "%1$@ (%2$lld)";
|
||||
"%lld" = "%lld";
|
||||
"%lld articles in the queue" = "%lld articles in the queue";
|
||||
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
|
||||
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
|
||||
"%lld min" = "%lld min";
|
||||
"%lld." = "%lld.";
|
||||
"%lld/%lld" = "%1$lld/%2$lld";
|
||||
"12 min • Today • example.com" = "12 min • Today • example.com";
|
||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
||||
|
||||
/* Legal & Privacy */
|
||||
"Legal & Privacy" = "Legal & Privacy";
|
||||
"Privacy Policy" = "Privacy Policy";
|
||||
"Legal Notice" = "Legal Notice";
|
||||
"Report an Issue" = "Report an Issue";
|
||||
"Contact Support" = "Contact Support";
|
||||
|
||||
/* Navigation & States */
|
||||
"All" = "All";
|
||||
"Unread" = "Unread";
|
||||
"Favorites" = "Favorites";
|
||||
"Archive" = "Archive";
|
||||
"Search" = "Search";
|
||||
"Settings" = "Settings";
|
||||
"Articles" = "Articles";
|
||||
"Videos" = "Videos";
|
||||
"Pictures" = "Pictures";
|
||||
"Tags" = "Tags";
|
||||
|
||||
/* Settings Sections */
|
||||
"Font Settings" = "Font Settings";
|
||||
"Appearance" = "Appearance";
|
||||
"Cache Settings" = "Cache Settings";
|
||||
"General Settings" = "General Settings";
|
||||
"Server Settings" = "Server Settings";
|
||||
"Server Connection" = "Server Connection";
|
||||
|
||||
"Add" = "Add";
|
||||
"Add new tag:" = "Add new tag:";
|
||||
"all" = "all";
|
||||
"All tags selected" = "All tags selected";
|
||||
"Archive" = "Archive";
|
||||
"Archive bookmark" = "Archive bookmark";
|
||||
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
|
||||
"Available tags" = "Available tags";
|
||||
"Most used tags" = "Most used tags";
|
||||
"Sorted by usage count" = "Sorted by usage count";
|
||||
"Sorted alphabetically" = "Sorted alphabetically";
|
||||
"Cancel" = "Cancel";
|
||||
"Category-specific Levels" = "Category-specific Levels";
|
||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
|
||||
"Close" = "Close";
|
||||
"Configure log levels and categories" = "Configure log levels and categories";
|
||||
"Critical" = "Critical";
|
||||
"Debug" = "Debug";
|
||||
"DEBUG BUILD" = "DEBUG BUILD";
|
||||
"Debug Settings" = "Debug Settings";
|
||||
"Delete" = "Delete";
|
||||
"Delete Bookmark" = "Delete Bookmark";
|
||||
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
|
||||
"Done" = "Done";
|
||||
"Enter an optional title..." = "Enter an optional title...";
|
||||
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
|
||||
"Error" = "Error";
|
||||
"Error: %@" = "Error: %@";
|
||||
"Favorite" = "Favorite";
|
||||
"Finished reading?" = "Finished reading?";
|
||||
"Font" = "Font";
|
||||
"Font family" = "Font family";
|
||||
"Font Settings" = "Font Settings";
|
||||
"Font size" = "Font size";
|
||||
"From Bremen with 💚" = "From Bremen with 💚";
|
||||
"General" = "General";
|
||||
"Global Level" = "Global Level";
|
||||
"Global Minimum Level" = "Global Minimum Level";
|
||||
"Global Settings" = "Global Settings";
|
||||
"https://example.com" = "https://example.com";
|
||||
"https://readeck.example.com" = "https://readeck.example.com";
|
||||
"Include Source Location" = "Include Source Location";
|
||||
"Info" = "Info";
|
||||
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
|
||||
"Key" = "Key";
|
||||
"Level for %@" = "Level for %@";
|
||||
"Loading %@" = "Loading %@";
|
||||
"Loading article..." = "Loading article...";
|
||||
"Logging Configuration" = "Logging Configuration";
|
||||
"Login & Save" = "Login & Save";
|
||||
"Logout" = "Logout";
|
||||
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
|
||||
"Manage Labels" = "Manage Labels";
|
||||
"Mark as favorite" = "Mark as favorite";
|
||||
"More" = "More";
|
||||
"New Bookmark" = "New Bookmark";
|
||||
"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 found in %@." = "No bookmarks found in %@.";
|
||||
"No bookmarks found." = "No bookmarks found.";
|
||||
"No results" = "No results";
|
||||
"Notice" = "Notice";
|
||||
"OK" = "OK";
|
||||
"Optional: Custom title" = "Optional: Custom title";
|
||||
"Password" = "Password";
|
||||
"Paste" = "Paste";
|
||||
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
|
||||
"Preview" = "Preview";
|
||||
"Progress: %lld%%" = "Progress: %lld%%";
|
||||
"Re-login & Save" = "Re-login & Save";
|
||||
"Read Aloud Feature" = "Read Aloud Feature";
|
||||
"Read article aloud" = "Read article aloud";
|
||||
"Read-aloud Queue" = "Read-aloud Queue";
|
||||
"readeck Bookmark Title" = "readeck Bookmark Title";
|
||||
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
|
||||
"Remove" = "Remove";
|
||||
"Reset" = "Reset";
|
||||
"Reset to Defaults" = "Reset to Defaults";
|
||||
"Restore" = "Restore";
|
||||
"Resume listening" = "Resume listening";
|
||||
"Save bookmark" = "Save bookmark";
|
||||
"Save Bookmark" = "Save Bookmark";
|
||||
"Saving..." = "Saving...";
|
||||
"Search" = "Search";
|
||||
"Search or add new tag..." = "Search or add new tag...";
|
||||
"Search results" = "Search results";
|
||||
"Search..." = "Search...";
|
||||
"Searching..." = "Searching...";
|
||||
"Select a bookmark or tag" = "Select a bookmark or tag";
|
||||
"Selected tags" = "Selected tags";
|
||||
"Server Endpoint" = "Server Endpoint";
|
||||
"Server not reachable - saving locally" = "Server not reachable - saving locally";
|
||||
"Settings" = "Settings";
|
||||
"Show Performance Logs" = "Show Performance Logs";
|
||||
"Show Timestamps" = "Show Timestamps";
|
||||
"Speed" = "Speed";
|
||||
"Syncing with server..." = "Syncing with server...";
|
||||
"Theme" = "Theme";
|
||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
|
||||
"Try Again" = "Try Again";
|
||||
"Unable to load bookmarks" = "Unable to load bookmarks";
|
||||
"Unarchive Bookmark" = "Unarchive Bookmark";
|
||||
"URL in clipboard:" = "URL in clipboard:";
|
||||
"Username" = "Username";
|
||||
"Version %@" = "Version %@";
|
||||
"Warning" = "Warning";
|
||||
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
|
||||
"Your Password" = "Your Password";
|
||||
"Your Username" = "Your Username";
|
||||
@ -4,6 +4,8 @@ import UIKit
|
||||
struct AddBookmarkView: View {
|
||||
@State private var viewModel = AddBookmarkViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@EnvironmentObject private var appSettings: AppSettings
|
||||
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
||||
@State private var keyboardHeight: CGFloat = 0
|
||||
|
||||
@ -58,9 +60,9 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.checkClipboard()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadAllLabels()
|
||||
Task {
|
||||
await viewModel.syncTags()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.clearForm()
|
||||
@ -74,11 +76,9 @@ struct AddBookmarkView: View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
urlField
|
||||
.id("urlField")
|
||||
Spacer()
|
||||
.frame(height: 40)
|
||||
.id("labelsOffset")
|
||||
labelsField
|
||||
.id("labelsField")
|
||||
@ -160,10 +160,11 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(12)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,24 +179,29 @@ struct AddBookmarkView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var labelsField: some View {
|
||||
TagManagementView(
|
||||
allLabels: viewModel.allLabels,
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: viewModel.isLabelsLoading,
|
||||
availableLabelPages: viewModel.availableLabelPages,
|
||||
filteredLabels: viewModel.filteredLabels,
|
||||
searchFieldFocus: $focusedField,
|
||||
onAddCustomTag: {
|
||||
viewModel.addCustomTag()
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
viewModel.toggleLabel(label)
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
viewModel.removeLabel(label)
|
||||
}
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
CoreDataTagManagementView(
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
searchText: $viewModel.searchText,
|
||||
searchFieldFocus: $focusedField,
|
||||
fetchLimit: nil,
|
||||
sortOrder: appSettings.tagSortOrder,
|
||||
context: viewContext,
|
||||
onAddCustomTag: {
|
||||
viewModel.addCustomTag()
|
||||
},
|
||||
onToggleLabel: { label in
|
||||
viewModel.toggleLabel(label)
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
viewModel.removeLabel(label)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@ -8,6 +8,8 @@ class AddBookmarkViewModel {
|
||||
|
||||
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
|
||||
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
|
||||
private let createLabelUseCase = DefaultUseCaseFactory.shared.makeCreateLabelUseCase()
|
||||
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
|
||||
|
||||
// MARK: - Form Data
|
||||
var url: String = ""
|
||||
@ -59,26 +61,20 @@ class AddBookmarkViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
var availableLabelPages: [[BookmarkLabel]] {
|
||||
let pageSize = 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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Labels Management
|
||||
|
||||
|
||||
/// Triggers background sync of tags from server to Core Data
|
||||
/// CoreDataTagManagementView will automatically update via @FetchRequest
|
||||
@MainActor
|
||||
func syncTags() async {
|
||||
try? await syncTagsUseCase.execute()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadAllLabels() async {
|
||||
isLabelsLoading = true
|
||||
defer { isLabelsLoading = false }
|
||||
|
||||
|
||||
do {
|
||||
let labels = try await getLabelsUseCase.execute()
|
||||
allLabels = labels.sorted { $0.count > $1.count }
|
||||
@ -92,17 +88,22 @@ class AddBookmarkViewModel {
|
||||
func addCustomTag() {
|
||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
|
||||
let lowercased = trimmed.lowercased()
|
||||
let allExisting = Set(allLabels.map { $0.name.lowercased() })
|
||||
let allSelected = Set(selectedLabels.map { $0.lowercased() })
|
||||
|
||||
|
||||
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
||||
// Tag already exists, don't add
|
||||
return
|
||||
} else {
|
||||
selectedLabels.insert(trimmed)
|
||||
searchText = ""
|
||||
|
||||
// Save new label to Core Data so it's available next time
|
||||
Task {
|
||||
try? await createLabelUseCase.execute(name: trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
readeck/UI/AppViewModel.swift
Normal file
98
readeck/UI/AppViewModel.swift
Normal file
@ -0,0 +1,98 @@
|
||||
//
|
||||
// AppViewModel.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 27.08.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
class AppViewModel {
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private let factory: UseCaseFactory
|
||||
private let syncTagsUseCase: PSyncTagsUseCase
|
||||
|
||||
var hasFinishedSetup: Bool = true
|
||||
var isServerReachable: Bool = false
|
||||
|
||||
private var lastAppStartTagSyncTime: Date?
|
||||
|
||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.factory = factory
|
||||
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
|
||||
setupNotificationObservers()
|
||||
|
||||
loadSetupStatus()
|
||||
}
|
||||
|
||||
private func setupNotificationObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .unauthorizedAPIResponse,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.handleUnauthorizedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .setupStatusChanged,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.loadSetupStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleUnauthorizedResponse() async {
|
||||
print("AppViewModel: Handling 401 Unauthorized - logging out user")
|
||||
|
||||
do {
|
||||
try await factory.makeLogoutUseCase().execute()
|
||||
loadSetupStatus()
|
||||
|
||||
print("AppViewModel: User successfully logged out due to 401 error")
|
||||
} catch {
|
||||
print("AppViewModel: Error during logout: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSetupStatus() {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
598
readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift
Normal file
598
readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift
Normal file
@ -0,0 +1,598 @@
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
// PreferenceKey for scroll offset tracking
|
||||
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGPoint = .zero
|
||||
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
// PreferenceKey for content height tracking
|
||||
struct ContentHeightPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkDetailLegacyView: View {
|
||||
let bookmarkId: String
|
||||
@Binding var useNativeWebView: Bool
|
||||
|
||||
// MARK: - States
|
||||
|
||||
@State private var viewModel: BookmarkDetailViewModel
|
||||
@State private var webViewHeight: CGFloat = 300
|
||||
@State private var contentEndPosition: CGFloat = 0
|
||||
@State private var initialContentEndPosition: CGFloat = 0
|
||||
@State private var showingFontSettings = false
|
||||
@State private var showingLabelsSheet = false
|
||||
@State private var showingAnnotationsSheet = false
|
||||
@State private var readingProgress: Double = 0.0
|
||||
@State private var lastSentProgress: Double = 0.0
|
||||
@State private var showJumpToProgressButton: Bool = false
|
||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||
@State private var showingImageViewer = false
|
||||
|
||||
// MARK: - Envs
|
||||
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let headerHeight: CGFloat = 360
|
||||
|
||||
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
|
||||
self.bookmarkId = bookmarkId
|
||||
self._useNativeWebView = useNativeWebView
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ProgressView(value: readingProgress)
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.frame(height: 3)
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
// Invisible GeometryReader to track scroll offset
|
||||
GeometryReader { scrollGeo in
|
||||
Color.clear.preference(
|
||||
key: ScrollOffsetPreferenceKey.self,
|
||||
value: CGPoint(
|
||||
x: scrollGeo.frame(in: .named("scrollView")).minX,
|
||||
y: scrollGeo.frame(in: .named("scrollView")).minY
|
||||
)
|
||||
)
|
||||
}
|
||||
.frame(height: 0)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .top) {
|
||||
headerView(width: geometry.size.width)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
||||
titleSection
|
||||
Divider().padding(.horizontal)
|
||||
if showJumpToProgressButton {
|
||||
JumpButton(containerHeight: geometry.size.height)
|
||||
}
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(
|
||||
htmlContent: viewModel.articleContent,
|
||||
settings: settings,
|
||||
onHeightChange: { height in
|
||||
if 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)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 4)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
|
||||
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
|
||||
VStack(alignment: .center) {
|
||||
archiveSection
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
.animation(.easeInOut, value: viewModel.articleContent)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// Invisible marker to measure total content height - placed AFTER all content
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.background(
|
||||
GeometryReader { endGeo in
|
||||
Color.clear.preference(
|
||||
key: ContentHeightPreferenceKey.self,
|
||||
value: endGeo.frame(in: .named("scrollView")).maxY
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "scrollView")
|
||||
.clipped()
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.scrollPosition($scrollPosition)
|
||||
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
|
||||
contentEndPosition = endPosition
|
||||
|
||||
let containerHeight = geometry.size.height
|
||||
|
||||
// Update initial position if content grows (WebView still loading) or first time
|
||||
// We always take the maximum position seen (when scrolled to top, this is total content height)
|
||||
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
|
||||
initialContentEndPosition = endPosition
|
||||
print("📏 Content end position updated: \(Int(endPosition)) (container: \(Int(containerHeight)))")
|
||||
}
|
||||
|
||||
// Calculate progress from how much the end marker has moved up
|
||||
guard initialContentEndPosition > 0 else {
|
||||
print("⏳ Waiting for content to load... current: \(Int(endPosition)), container: \(Int(containerHeight))")
|
||||
return
|
||||
}
|
||||
|
||||
let totalScrollableDistance = initialContentEndPosition - containerHeight
|
||||
|
||||
guard totalScrollableDistance > 0 else {
|
||||
print("⚠️ Content not scrollable: initial=\(initialContentEndPosition), container=\(containerHeight)")
|
||||
return
|
||||
}
|
||||
|
||||
// How far has the marker moved from its initial position?
|
||||
let scrolled = initialContentEndPosition - endPosition
|
||||
let rawProgress = scrolled / totalScrollableDistance
|
||||
var progress = min(max(rawProgress, 0), 1)
|
||||
|
||||
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
|
||||
if lastSentProgress >= 0.995 {
|
||||
progress = max(progress, 1.0)
|
||||
}
|
||||
|
||||
print("📊 Progress: \(Int(progress * 100))% | scrolled: \(Int(scrolled)) / \(Int(totalScrollableDistance)) | endPos: \(Int(endPosition))")
|
||||
|
||||
// Check if we should update: threshold OR reaching 100% for first time
|
||||
let threshold: Double = 0.03
|
||||
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
|
||||
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
|
||||
|
||||
if shouldUpdate {
|
||||
print("✅ Updating progress: \(Int(lastSentProgress * 100))% → \(Int(progress * 100))%\(reachedEnd ? " [END]" : "")")
|
||||
lastSentProgress = progress
|
||||
readingProgress = progress
|
||||
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
|
||||
// Not needed anymore, we track via ContentHeightPreferenceKey
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
#if DEBUG
|
||||
// Toggle button (left)
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
if #available(iOS 26.0, *) {
|
||||
Button(action: {
|
||||
useNativeWebView.toggle()
|
||||
}) {
|
||||
Image(systemName: "waveform")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Top toolbar (right)
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
showingLabelsSheet = true
|
||||
}) {
|
||||
Image(systemName: "tag")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingAnnotationsSheet = true
|
||||
}) {
|
||||
Image(systemName: "pencil.line")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingFontSettings = true
|
||||
}) {
|
||||
Image(systemName: "textformat")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingFontSettings) {
|
||||
NavigationView {
|
||||
VStack {
|
||||
FontSettingsView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("Font Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
showingFontSettings = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingLabelsSheet) {
|
||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||
}
|
||||
.sheet(isPresented: $showingAnnotationsSheet) {
|
||||
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
|
||||
viewModel.selectedAnnotationId = annotationId
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingImageViewer) {
|
||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
||||
}
|
||||
.onChange(of: showingFontSettings) { _, isShowing in
|
||||
if !isShowing {
|
||||
// Reload settings when sheet is dismissed
|
||||
Task {
|
||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: showingLabelsSheet) { _, isShowing in
|
||||
if !isShowing {
|
||||
// Reload bookmark detail when labels sheet is dismissed
|
||||
Task {
|
||||
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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
|
||||
showJumpToProgressButton = progress > 0 && progress < 100
|
||||
}
|
||||
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
|
||||
// Trigger WebView reload when annotation is selected
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||
await viewModel.loadArticleContent(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewBuilder
|
||||
|
||||
@ViewBuilder
|
||||
private func headerView(width: CGFloat) -> some View {
|
||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: width, height: headerHeight)
|
||||
.clipped()
|
||||
|
||||
// Zoom icon
|
||||
Button(action: {
|
||||
showingImageViewer = true
|
||||
}) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.onTapGesture {
|
||||
showingImageViewer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var titleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(viewModel.bookmarkDetail.title)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.bottom, 2)
|
||||
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
|
||||
metaInfoSection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
|
||||
withAnimation(.easeInOut(duration: 0.1)) {
|
||||
webViewHeight = height
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 4)
|
||||
.animation(.easeInOut, value: webViewHeight)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var metaInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !viewModel.bookmarkDetail.authors.isEmpty {
|
||||
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
||||
}
|
||||
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
|
||||
|
||||
// Labels section
|
||||
if !viewModel.bookmarkDetail.labels.isEmpty {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "tag")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 2)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.accentColor.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metaRow(icon: "safari") {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if appSettings.enableTTS {
|
||||
metaRow(icon: "speaker.wave.2") {
|
||||
Button(action: {
|
||||
viewModel.addBookmarkToSpeechQueue()
|
||||
playerUIState.showPlayer()
|
||||
}) {
|
||||
Text("Read article aloud")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metaRow(icon: String, text: String) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private var archiveSection: some View {
|
||||
VStack(alignment: .center, spacing: 12) {
|
||||
Text("Finished reading?")
|
||||
.font(.headline)
|
||||
.padding(.top, 24)
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.toggleFavorite(id: bookmarkId)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
|
||||
.foregroundColor(viewModel.bookmarkDetail.isMarked ? .yellow : .gray)
|
||||
Text(viewModel.bookmarkDetail.isMarked ? "Favorite" : "Mark as favorite")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxHeight: 60)
|
||||
.padding(10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(viewModel.isLoading)
|
||||
|
||||
// Archive button
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
|
||||
Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxHeight: 60)
|
||||
.padding(10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func JumpButton(containerHeight: CGFloat) -> some View {
|
||||
Button(action: {
|
||||
let maxOffset = webViewHeight - containerHeight
|
||||
let offset = maxOffset * (Double(viewModel.readProgress) / 100.0)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
scrollPosition = ScrollPosition(y: offset)
|
||||
showJumpToProgressButton = false
|
||||
}
|
||||
}) {
|
||||
Text("Jump to last read position (\(viewModel.readProgress)%)")
|
||||
.font(.subheadline)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
.padding([.top, .horizontal])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
BookmarkDetailLegacyView(
|
||||
bookmarkId: "123",
|
||||
useNativeWebView: .constant(false),
|
||||
viewModel: .init(MockUseCaseFactory())
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,503 +1,30 @@
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
import Combine
|
||||
|
||||
/// Container view that routes to the appropriate BookmarkDetail implementation
|
||||
/// based on iOS version availability or user preference
|
||||
struct BookmarkDetailView: View {
|
||||
let bookmarkId: String
|
||||
let namespace: Namespace.ID?
|
||||
|
||||
// MARK: - States
|
||||
|
||||
@State private var viewModel: BookmarkDetailViewModel
|
||||
@State private var webViewHeight: CGFloat = 300
|
||||
@State private var showingFontSettings = false
|
||||
@State private var showingLabelsSheet = false
|
||||
@State private var readingProgress: Double = 0.0
|
||||
@State private var scrollViewHeight: CGFloat = 1
|
||||
@State private var showJumpToProgressButton: Bool = false
|
||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||
@State private var showingImageViewer = false
|
||||
|
||||
// MARK: - Envs
|
||||
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let headerHeight: CGFloat = 320
|
||||
|
||||
init(bookmarkId: String, namespace: Namespace.ID? = nil, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
|
||||
self.bookmarkId = bookmarkId
|
||||
self.namespace = namespace
|
||||
self.viewModel = viewModel
|
||||
self.webViewHeight = webViewHeight
|
||||
self.showingFontSettings = showingFontSettings
|
||||
self.showingLabelsSheet = showingLabelsSheet
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ProgressView(value: readingProgress)
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.frame(height: 3)
|
||||
GeometryReader { outerGeo in
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.preference(key: ScrollOffsetPreferenceKey.self,
|
||||
value: geo.frame(in: .named("scroll")).minY)
|
||||
}
|
||||
.frame(height: 0)
|
||||
ZStack(alignment: .top) {
|
||||
headerView(geometry: outerGeo)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
||||
titleSection
|
||||
Divider().padding(.horizontal)
|
||||
if showJumpToProgressButton {
|
||||
JumpButton()
|
||||
}
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in
|
||||
if webViewHeight != height {
|
||||
webViewHeight = height
|
||||
}
|
||||
})
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal)
|
||||
.animation(.easeInOut, value: webViewHeight)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
|
||||
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
|
||||
VStack(alignment: .center) {
|
||||
archiveSection
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
.animation(.easeInOut, value: viewModel.articleContent)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "scroll")
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
||||
scrollViewHeight = outerGeo.size.height
|
||||
let maxOffset = webViewHeight - scrollViewHeight
|
||||
let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1)
|
||||
let progress = min(max(rawProgress, 0), 1)
|
||||
readingProgress = progress
|
||||
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.scrollPosition($scrollPosition)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
showingLabelsSheet = true
|
||||
}) {
|
||||
Image(systemName: "tag")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingFontSettings = true
|
||||
}) {
|
||||
Image(systemName: "textformat")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingFontSettings) {
|
||||
NavigationView {
|
||||
VStack {
|
||||
FontSettingsView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("Font Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
showingFontSettings = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingLabelsSheet) {
|
||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||
}
|
||||
.sheet(isPresented: $showingImageViewer) {
|
||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
||||
}
|
||||
.onChange(of: showingFontSettings) { _, isShowing in
|
||||
if !isShowing {
|
||||
// Reload settings when sheet is dismissed
|
||||
Task {
|
||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: showingLabelsSheet) { _, isShowing in
|
||||
if !isShowing {
|
||||
// Reload bookmark detail when labels sheet is dismissed
|
||||
Task {
|
||||
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.readProgress) { _, progress in
|
||||
showJumpToProgressButton = progress > 0 && progress < 100
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||
await viewModel.loadArticleContent(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewBuilder
|
||||
|
||||
@ViewBuilder
|
||||
private func headerView(geometry: GeometryProxy) -> some View {
|
||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||
GeometryReader { geo in
|
||||
let offset = geo.frame(in: .global).minY
|
||||
ZStack(alignment: .top) {
|
||||
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||
.clipped()
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
.if(namespace != nil) { view in
|
||||
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
|
||||
}
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.4))
|
||||
.frame(width: geometry.size.width, height: headerHeight)
|
||||
.if(namespace != nil) { view in
|
||||
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
|
||||
}
|
||||
}
|
||||
// Gradient overlay für bessere Button-Sichtbarkeit
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.black.opacity(1.0),
|
||||
Color.black.opacity(0.9),
|
||||
Color.black.opacity(0.7),
|
||||
Color.black.opacity(0.4),
|
||||
Color.black.opacity(0.2),
|
||||
Color.clear
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 240)
|
||||
.frame(maxWidth: .infinity)
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
|
||||
// Tap area and zoom icon
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showingImageViewer = true
|
||||
}) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight + (offset > 0 ? offset : 0))
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.onTapGesture {
|
||||
showingImageViewer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var titleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(viewModel.bookmarkDetail.title)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.bottom, 2)
|
||||
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
|
||||
metaInfoSection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
|
||||
withAnimation(.easeInOut(duration: 0.1)) {
|
||||
webViewHeight = height
|
||||
}
|
||||
}
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal)
|
||||
.animation(.easeInOut, value: webViewHeight)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var metaInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !viewModel.bookmarkDetail.authors.isEmpty {
|
||||
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
||||
}
|
||||
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
|
||||
|
||||
// Labels section
|
||||
if !viewModel.bookmarkDetail.labels.isEmpty {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "tag")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 2)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.accentColor.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metaRow(icon: "safari") {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if appSettings.enableTTS {
|
||||
metaRow(icon: "speaker.wave.2") {
|
||||
Button(action: {
|
||||
viewModel.addBookmarkToSpeechQueue()
|
||||
playerUIState.showPlayer()
|
||||
}) {
|
||||
Text("Read article aloud")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metaRow(icon: String, text: String) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private var archiveSection: some View {
|
||||
VStack(alignment: .center, spacing: 12) {
|
||||
Text("Finished reading?")
|
||||
.font(.headline)
|
||||
.padding(.top, 24)
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.toggleFavorite(id: bookmarkId)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
|
||||
.foregroundColor(viewModel.bookmarkDetail.isMarked ? .yellow : .gray)
|
||||
Text(viewModel.bookmarkDetail.isMarked ? "Favorite" : "Mark as favorite")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxHeight: 60)
|
||||
.padding(10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(viewModel.isLoading)
|
||||
|
||||
// Archive button
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
|
||||
Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxHeight: 60)
|
||||
.padding(10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func JumpButton() -> some View {
|
||||
Button(action: {
|
||||
let maxOffset = webViewHeight - scrollViewHeight
|
||||
let offset = maxOffset * (Double(viewModel.readProgress) / 100.0)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
scrollPosition = ScrollPosition(y: offset)
|
||||
showJumpToProgressButton = false
|
||||
}
|
||||
}) {
|
||||
Text("Jump to last read position (\(viewModel.readProgress)%)")
|
||||
.font(.subheadline)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
.padding([.top, .horizontal])
|
||||
}
|
||||
}
|
||||
|
||||
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
typealias Value = CGFloat
|
||||
static var defaultValue = CGFloat.zero
|
||||
static func reduce(value: inout Value, nextValue: () -> Value) {
|
||||
value += nextValue()
|
||||
@AppStorage("useNativeWebView") private var useNativeWebView: Bool = true
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
if useNativeWebView {
|
||||
// Use modern SwiftUI-native implementation on iOS 26+
|
||||
BookmarkDetailView2(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView)
|
||||
} else {
|
||||
// Use legacy WKWebView-based implementation
|
||||
BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: $useNativeWebView)
|
||||
}
|
||||
} else {
|
||||
// iOS < 26: always use Legacy
|
||||
BookmarkDetailLegacyView(bookmarkId: bookmarkId, useNativeWebView: .constant(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
BookmarkDetailView(bookmarkId: "123",
|
||||
viewModel: .init(MockUseCaseFactory()),
|
||||
webViewHeight: 300,
|
||||
showingFontSettings: false,
|
||||
showingLabelsSheet: false,
|
||||
playerUIState: .init())
|
||||
BookmarkDetailView(bookmarkId: "123")
|
||||
}
|
||||
}
|
||||
|
||||
566
readeck/UI/BookmarkDetail/BookmarkDetailView2.swift
Normal file
566
readeck/UI/BookmarkDetail/BookmarkDetailView2.swift
Normal file
@ -0,0 +1,566 @@
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
@available(iOS 26.0, *)
|
||||
struct BookmarkDetailView2: View {
|
||||
let bookmarkId: String
|
||||
@Binding var useNativeWebView: Bool
|
||||
|
||||
// MARK: - States
|
||||
|
||||
@State private var viewModel: BookmarkDetailViewModel
|
||||
@State private var webViewHeight: CGFloat = 300
|
||||
@State private var contentEndPosition: CGFloat = 0
|
||||
@State private var initialContentEndPosition: CGFloat = 0
|
||||
@State private var showingFontSettings = false
|
||||
@State private var showingLabelsSheet = false
|
||||
@State private var showingAnnotationsSheet = false
|
||||
@State private var readingProgress: Double = 0.0
|
||||
@State private var lastSentProgress: Double = 0.0
|
||||
@State private var showJumpToProgressButton: Bool = false
|
||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||
@State private var showingImageViewer = false
|
||||
|
||||
// MARK: - Envs
|
||||
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let headerHeight: CGFloat = 360
|
||||
|
||||
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
|
||||
self.bookmarkId = bookmarkId
|
||||
self._useNativeWebView = useNativeWebView
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
mainView
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
content
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
toolbarContent
|
||||
}
|
||||
.sheet(isPresented: $showingFontSettings) {
|
||||
fontSettingsSheet
|
||||
}
|
||||
.sheet(isPresented: $showingLabelsSheet) {
|
||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||
}
|
||||
.sheet(isPresented: $showingAnnotationsSheet) {
|
||||
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
|
||||
viewModel.selectedAnnotationId = annotationId
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingImageViewer) {
|
||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
||||
}
|
||||
.onChange(of: showingFontSettings) { _, isShowing in
|
||||
if !isShowing {
|
||||
Task {
|
||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: showingLabelsSheet) { _, isShowing in
|
||||
if !isShowing {
|
||||
Task {
|
||||
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: showingAnnotationsSheet) { _, isShowing in
|
||||
if !isShowing {
|
||||
Task {
|
||||
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.readProgress) { _, progress in
|
||||
showJumpToProgressButton = progress > 0 && progress < 100
|
||||
}
|
||||
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
|
||||
// Trigger WebView reload when annotation is selected
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||
await viewModel.loadArticleContent(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Progress bar at top
|
||||
ProgressView(value: readingProgress)
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.frame(height: 3)
|
||||
|
||||
// Main scroll content
|
||||
scrollViewContent
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
|
||||
if readingProgress >= 0.9 {
|
||||
floatingActionButtons
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: readingProgress >= 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
private var floatingActionButtons: some View {
|
||||
GlassEffectContainer(spacing: 52.0) {
|
||||
HStack(spacing: 52.0) {
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.toggleFavorite(id: bookmarkId)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
|
||||
.foregroundStyle(viewModel.bookmarkDetail.isMarked ? .yellow : .primary)
|
||||
.frame(width: 52.0, height: 52.0)
|
||||
.font(.system(size: 31))
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
.glassEffect()
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
|
||||
.frame(width: 52.0, height: 52.0)
|
||||
.font(.system(size: 31))
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
.glassEffect()
|
||||
.offset(x: -52.0, y: 0.0)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 1)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
private var scrollViewContent: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
// Invisible GeometryReader to track scroll offset
|
||||
GeometryReader { scrollGeo in
|
||||
Color.clear.preference(
|
||||
key: ScrollOffsetPreferenceKey.self,
|
||||
value: CGPoint(
|
||||
x: scrollGeo.frame(in: .named("scrollView")).minX,
|
||||
y: scrollGeo.frame(in: .named("scrollView")).minY
|
||||
)
|
||||
)
|
||||
}
|
||||
.frame(height: 0)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .top) {
|
||||
headerView(width: geometry.size.width)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Color.clear.frame(width: geometry.size.width, height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
||||
|
||||
titleSection
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
if showJumpToProgressButton {
|
||||
jumpButton(containerHeight: geometry.size.height)
|
||||
}
|
||||
|
||||
// Article content (WebView)
|
||||
articleContent
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// Invisible marker to measure total content height - placed AFTER all content
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.background(
|
||||
GeometryReader { endGeo in
|
||||
Color.clear.preference(
|
||||
key: ContentHeightPreferenceKey.self,
|
||||
value: endGeo.frame(in: .named("scrollView")).maxY
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "scrollView")
|
||||
.clipped()
|
||||
.ignoresSafeArea(edges: [.top, .bottom])
|
||||
.scrollPosition($scrollPosition)
|
||||
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
|
||||
contentEndPosition = endPosition
|
||||
|
||||
let containerHeight = geometry.size.height
|
||||
|
||||
// Update initial position if content grows (WebView still loading) or first time
|
||||
// We always take the maximum position seen (when scrolled to top, this is total content height)
|
||||
if endPosition > initialContentEndPosition && endPosition > containerHeight * 1.2 {
|
||||
initialContentEndPosition = endPosition
|
||||
}
|
||||
|
||||
// Calculate progress from how much the end marker has moved up
|
||||
guard initialContentEndPosition > 0 else { return }
|
||||
|
||||
let totalScrollableDistance = initialContentEndPosition - containerHeight
|
||||
|
||||
guard totalScrollableDistance > 0 else { return }
|
||||
|
||||
// How far has the marker moved from its initial position?
|
||||
let scrolled = initialContentEndPosition - endPosition
|
||||
let rawProgress = scrolled / totalScrollableDistance
|
||||
var progress = min(max(rawProgress, 0), 1)
|
||||
|
||||
// Lock progress at 100% once reached (don't go back to 99% due to pixel variations)
|
||||
if lastSentProgress >= 0.995 {
|
||||
progress = max(progress, 1.0)
|
||||
}
|
||||
|
||||
// Check if we should update: threshold OR reaching 100% for first time
|
||||
let threshold: Double = 0.03
|
||||
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
|
||||
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
|
||||
|
||||
readingProgress = progress
|
||||
|
||||
if shouldUpdate {
|
||||
lastSentProgress = progress
|
||||
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { _ in
|
||||
// Not needed anymore, we track via ContentHeightPreferenceKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
|
||||
#if DEBUG
|
||||
// Toggle button (left)
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(action: {
|
||||
useNativeWebView.toggle()
|
||||
}) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Top toolbar (right)
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
showingLabelsSheet = true
|
||||
}) {
|
||||
Image(systemName: "tag")
|
||||
}
|
||||
|
||||
if viewModel.hasAnnotations {
|
||||
Button(action: {
|
||||
showingAnnotationsSheet = true
|
||||
}) {
|
||||
Image(systemName: "pencil.line")
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingFontSettings = true
|
||||
}) {
|
||||
Image(systemName: "textformat")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fontSettingsSheet: some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
FontSettingsView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("Font Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
showingFontSettings = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewBuilder
|
||||
|
||||
@ViewBuilder
|
||||
private func headerView(width: CGFloat) -> some View {
|
||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
// Background blur for images that don't fill
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: width, height: headerHeight)
|
||||
.blur(radius: 30)
|
||||
.clipped()
|
||||
|
||||
// Main image with fit
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: width, height: headerHeight)
|
||||
|
||||
// Zoom icon
|
||||
Button(action: {
|
||||
showingImageViewer = true
|
||||
}) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.frame(width: width, height: headerHeight)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.onTapGesture {
|
||||
showingImageViewer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var titleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(viewModel.bookmarkDetail.title)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.bottom, 2)
|
||||
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
|
||||
metaInfoSection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private var metaInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !viewModel.bookmarkDetail.authors.isEmpty {
|
||||
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Authors: " : "Author: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
||||
}
|
||||
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) words • \(viewModel.bookmarkDetail.readingTime ?? 0) min read")
|
||||
|
||||
// Labels section
|
||||
if !viewModel.bookmarkDetail.labels.isEmpty {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "tag")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 2)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.accentColor.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metaRow(icon: "safari") {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if appSettings.enableTTS {
|
||||
metaRow(icon: "speaker.wave.2") {
|
||||
Button(action: {
|
||||
viewModel.addBookmarkToSpeechQueue()
|
||||
playerUIState.showPlayer()
|
||||
}) {
|
||||
Text("Read article aloud")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metaRow(icon: String, text: String) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var articleContent: some View {
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
if #available(iOS 26.0, *) {
|
||||
NativeWebView(
|
||||
htmlContent: viewModel.articleContent,
|
||||
settings: settings,
|
||||
onHeightChange: { height in
|
||||
if 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)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func jumpButton(containerHeight: CGFloat) -> some View {
|
||||
Button(action: {
|
||||
let maxOffset = webViewHeight - containerHeight
|
||||
let offset = maxOffset * (Double(viewModel.readProgress) / 100.0)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
scrollPosition = ScrollPosition(y: offset)
|
||||
showJumpToProgressButton = false
|
||||
}
|
||||
}) {
|
||||
Text("Jump to last read position (\(viewModel.readProgress)%)")
|
||||
.font(.subheadline)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
.padding([.top, .horizontal])
|
||||
}
|
||||
|
||||
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 {
|
||||
if #available(iOS 26.0, *) {
|
||||
NavigationView {
|
||||
BookmarkDetailView2(
|
||||
bookmarkId: "123",
|
||||
useNativeWebView: .constant(true),
|
||||
viewModel: .init(MockUseCaseFactory())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,8 @@ class BookmarkDetailViewModel {
|
||||
private let loadSettingsUseCase: PLoadSettingsUseCase
|
||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
|
||||
|
||||
private let api: PAPI
|
||||
|
||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||
var articleContent: String = ""
|
||||
var articleParagraphs: [String] = []
|
||||
@ -18,7 +19,9 @@ class BookmarkDetailViewModel {
|
||||
var errorMessage: String?
|
||||
var settings: Settings?
|
||||
var readProgress: Int = 0
|
||||
|
||||
var selectedAnnotationId: String?
|
||||
var hasAnnotations: Bool = false
|
||||
|
||||
private var factory: UseCaseFactory?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>()
|
||||
@ -28,8 +31,9 @@ class BookmarkDetailViewModel {
|
||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||
self.api = API()
|
||||
self.factory = factory
|
||||
|
||||
|
||||
readProgressSubject
|
||||
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] (id, progress, anchor) in
|
||||
@ -67,23 +71,26 @@ class BookmarkDetailViewModel {
|
||||
@MainActor
|
||||
func loadArticleContent(id: String) async {
|
||||
isLoadingArticle = true
|
||||
|
||||
|
||||
do {
|
||||
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
|
||||
processArticleContent()
|
||||
} catch {
|
||||
errorMessage = "Error loading article"
|
||||
}
|
||||
|
||||
|
||||
isLoadingArticle = false
|
||||
}
|
||||
|
||||
|
||||
private func processArticleContent() {
|
||||
let paragraphs = articleContent
|
||||
.components(separatedBy: .newlines)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
|
||||
|
||||
articleParagraphs = paragraphs
|
||||
|
||||
// Check if article contains annotations
|
||||
hasAnnotations = articleContent.contains("<rd-annotation")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -137,4 +144,22 @@ class BookmarkDetailViewModel {
|
||||
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
|
||||
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
|
||||
@State private var viewModel: BookmarkLabelsViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@EnvironmentObject private var appSettings: AppSettings
|
||||
|
||||
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) {
|
||||
self.bookmarkId = bookmarkId
|
||||
@ -40,13 +42,15 @@ struct BookmarkLabelsView: View {
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "Unknown error")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadAllLabels()
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.onTapGesture {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.syncTags()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,30 +60,36 @@ struct BookmarkLabelsView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var availableLabelsSection: some View {
|
||||
TagManagementView(
|
||||
allLabels: viewModel.allLabels,
|
||||
selectedLabels: Set(viewModel.currentLabels),
|
||||
searchText: $viewModel.searchText,
|
||||
isLabelsLoading: viewModel.isInitialLoading,
|
||||
availableLabelPages: viewModel.availableLabelPages,
|
||||
filteredLabels: viewModel.filteredLabels,
|
||||
onAddCustomTag: {
|
||||
Task {
|
||||
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
|
||||
CoreDataTagManagementView(
|
||||
selectedLabels: Set(viewModel.currentLabels),
|
||||
searchText: $viewModel.searchText,
|
||||
fetchLimit: nil,
|
||||
sortOrder: appSettings.tagSortOrder,
|
||||
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
|
||||
Task {
|
||||
await viewModel.toggleLabel(for: bookmarkId, label: label)
|
||||
}
|
||||
},
|
||||
onRemoveLabel: { label in
|
||||
Task {
|
||||
await viewModel.removeLabel(from: bookmarkId, label: label)
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,60 +5,46 @@ class BookmarkLabelsViewModel {
|
||||
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
|
||||
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
|
||||
private let getLabelsUseCase: PGetLabelsUseCase
|
||||
private let syncTagsUseCase: PSyncTagsUseCase
|
||||
|
||||
var isLoading = false
|
||||
var isInitialLoading = false
|
||||
var errorMessage: String?
|
||||
var showErrorAlert = false
|
||||
var currentLabels: [String] = [] {
|
||||
didSet {
|
||||
if oldValue != currentLabels {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
var currentLabels: [String] = []
|
||||
var newLabelText = ""
|
||||
var searchText = "" {
|
||||
didSet {
|
||||
if oldValue != searchText {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
var searchText = ""
|
||||
|
||||
var allLabels: [BookmarkLabel] = [] {
|
||||
didSet {
|
||||
if oldValue != allLabels {
|
||||
calculatePages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var labelPages: [[BookmarkLabel]] = []
|
||||
|
||||
// Cached properties to avoid recomputation
|
||||
private var _availableLabels: [BookmarkLabel] = []
|
||||
private var _filteredLabels: [BookmarkLabel] = []
|
||||
var allLabels: [BookmarkLabel] = []
|
||||
|
||||
var availableLabels: [BookmarkLabel] {
|
||||
return _availableLabels
|
||||
return allLabels.filter { !currentLabels.contains($0.name) }
|
||||
}
|
||||
|
||||
var filteredLabels: [BookmarkLabel] {
|
||||
return _filteredLabels
|
||||
if searchText.isEmpty {
|
||||
return availableLabels
|
||||
} else {
|
||||
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
}
|
||||
|
||||
var availableLabelPages: [[BookmarkLabel]] = []
|
||||
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
|
||||
self.currentLabels = initialLabels
|
||||
|
||||
|
||||
self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase()
|
||||
self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase()
|
||||
self.getLabelsUseCase = factory.makeGetLabelsUseCase()
|
||||
|
||||
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
|
||||
}
|
||||
|
||||
/// Triggers background sync of tags from server to Core Data
|
||||
/// CoreDataTagManagementView will automatically update via @FetchRequest
|
||||
@MainActor
|
||||
func syncTags() async {
|
||||
try? await syncTagsUseCase.execute()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadAllLabels() async {
|
||||
isInitialLoading = true
|
||||
@ -70,8 +56,6 @@ class BookmarkLabelsViewModel {
|
||||
errorMessage = "failed to load labels"
|
||||
showErrorAlert = true
|
||||
}
|
||||
|
||||
calculatePages()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -97,10 +81,12 @@ class BookmarkLabelsViewModel {
|
||||
|
||||
@MainActor
|
||||
func addLabel(to bookmarkId: String, label: String) async {
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedLabel.isEmpty else { return }
|
||||
let splitLabels = LabelUtils.splitLabelsFromInput(label)
|
||||
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels)
|
||||
|
||||
await addLabels(to: bookmarkId, labels: [trimmedLabel])
|
||||
guard !uniqueLabels.isEmpty else { return }
|
||||
|
||||
await addLabels(to: bookmarkId, labels: uniqueLabels)
|
||||
newLabelText = ""
|
||||
searchText = ""
|
||||
}
|
||||
@ -143,36 +129,4 @@ class BookmarkLabelsViewModel {
|
||||
func updateLabels(_ labels: [String]) {
|
||||
currentLabels = labels
|
||||
}
|
||||
|
||||
private func calculatePages() {
|
||||
let pageSize = Constants.Labels.pageSize
|
||||
|
||||
// Update cached available labels
|
||||
_availableLabels = allLabels.filter { !currentLabels.contains($0.name) }
|
||||
|
||||
// Update cached filtered labels
|
||||
if searchText.isEmpty {
|
||||
_filteredLabels = _availableLabels
|
||||
} else {
|
||||
_filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
// Calculate pages for all labels
|
||||
if allLabels.count <= pageSize {
|
||||
labelPages = [allLabels]
|
||||
} else {
|
||||
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
|
||||
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate pages for filtered labels
|
||||
if _filteredLabels.count <= pageSize {
|
||||
availableLabelPages = [_filteredLabels]
|
||||
} else {
|
||||
availableLabelPages = stride(from: 0, to: _filteredLabels.count, by: pageSize).map {
|
||||
Array(_filteredLabels[$0..<min($0 + pageSize, _filteredLabels.count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,102 +17,91 @@ struct ImageViewerView: View {
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
|
||||
AsyncImage(url: URL(string: imageUrl)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(offset)
|
||||
.offset(dragOffset)
|
||||
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let delta = value / lastScale
|
||||
lastScale = value
|
||||
scale = min(max(scale * delta, 1), 4)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = 1.0
|
||||
if scale < 1 {
|
||||
withAnimation(.spring()) {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
if scale > 4 {
|
||||
scale = 4
|
||||
}
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
if scale > 1 {
|
||||
let newOffset = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
height: lastOffset.height + value.translation.height
|
||||
)
|
||||
offset = newOffset
|
||||
} else {
|
||||
// Dismiss gesture when not zoomed
|
||||
dragOffset = value.translation
|
||||
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
||||
if dragDistance > 50 {
|
||||
isDraggingToDismiss = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
if scale <= 1 {
|
||||
lastOffset = offset
|
||||
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
||||
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
|
||||
|
||||
if dragDistance > 100 || velocity > 500 {
|
||||
dismiss()
|
||||
} else {
|
||||
withAnimation(.spring()) {
|
||||
dragOffset = .zero
|
||||
isDraggingToDismiss = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastOffset = offset
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation(.spring()) {
|
||||
if scale > 1 {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
} else {
|
||||
scale = 2
|
||||
CachedAsyncImage(url: URL(string: imageUrl))
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(offset)
|
||||
.offset(dragOffset)
|
||||
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let delta = value / lastScale
|
||||
lastScale = value
|
||||
scale = min(max(scale * delta, 1), 4)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = 1.0
|
||||
if scale < 1 {
|
||||
withAnimation(.spring()) {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
if scale > 4 {
|
||||
scale = 4
|
||||
}
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
if scale > 1 {
|
||||
let newOffset = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
height: lastOffset.height + value.translation.height
|
||||
)
|
||||
offset = newOffset
|
||||
} else {
|
||||
// Dismiss gesture when not zoomed
|
||||
dragOffset = value.translation
|
||||
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
||||
if dragDistance > 50 {
|
||||
isDraggingToDismiss = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
if scale <= 1 {
|
||||
lastOffset = offset
|
||||
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
||||
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
|
||||
|
||||
if dragDistance > 100 || velocity > 500 {
|
||||
dismiss()
|
||||
} else {
|
||||
withAnimation(.spring()) {
|
||||
dragOffset = .zero
|
||||
isDraggingToDismiss = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastOffset = offset
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation(.spring()) {
|
||||
if scale > 1 {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
} else {
|
||||
scale = 2
|
||||
}
|
||||
}
|
||||
} placeholder: {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ImageViewerView(imageUrl: "https://example.com/image.jpg")
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import SafariServices
|
||||
|
||||
extension View {
|
||||
@ -12,35 +13,192 @@ extension View {
|
||||
}
|
||||
|
||||
struct BookmarkCardView: View {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
let bookmark: Bookmark
|
||||
let currentState: BookmarkState
|
||||
let layout: CardLayoutStyle
|
||||
let pendingDelete: PendingDelete?
|
||||
let onArchive: (Bookmark) -> Void
|
||||
let onDelete: (Bookmark) -> Void
|
||||
let onToggleFavorite: (Bookmark) -> Void
|
||||
let namespace: Namespace.ID?
|
||||
let onUndoDelete: ((String) -> Void)?
|
||||
|
||||
init(
|
||||
bookmark: Bookmark,
|
||||
currentState: BookmarkState,
|
||||
layout: CardLayoutStyle = .magazine,
|
||||
pendingDelete: PendingDelete? = nil,
|
||||
onArchive: @escaping (Bookmark) -> Void,
|
||||
onDelete: @escaping (Bookmark) -> Void,
|
||||
onToggleFavorite: @escaping (Bookmark) -> Void,
|
||||
onUndoDelete: ((String) -> Void)? = nil
|
||||
) {
|
||||
self.bookmark = bookmark
|
||||
self.currentState = currentState
|
||||
self.layout = layout
|
||||
self.pendingDelete = pendingDelete
|
||||
self.onArchive = onArchive
|
||||
self.onDelete = onDelete
|
||||
self.onToggleFavorite = onToggleFavorite
|
||||
self.onUndoDelete = onUndoDelete
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Group {
|
||||
switch layout {
|
||||
case .compact:
|
||||
compactLayoutView
|
||||
case .magazine:
|
||||
magazineLayoutView
|
||||
case .natural:
|
||||
naturalLayoutView
|
||||
}
|
||||
}
|
||||
.opacity(pendingDelete != nil ? 0.4 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.2), value: pendingDelete != nil)
|
||||
|
||||
// Undo toast overlay with progress background
|
||||
if let pendingDelete = pendingDelete {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Undo button area with circular progress
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
// Circular progress indicator
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 2)
|
||||
.frame(width: 16, height: 16)
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(pendingDelete.progress))
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.animation(.linear(duration: 0.1), value: pendingDelete.progress)
|
||||
}
|
||||
|
||||
Text("Deleting...")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Undo") {
|
||||
onUndoDelete?(bookmark.id)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(.blue)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
.onTapGesture {
|
||||
onUndoDelete?(bookmark.id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: layout == .compact ? 8 : 12))
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if pendingDelete == nil {
|
||||
Button("Delete", role: .destructive) {
|
||||
onDelete(bookmark)
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if pendingDelete == nil {
|
||||
Button {
|
||||
onArchive(bookmark)
|
||||
} label: {
|
||||
if currentState == .archived {
|
||||
Label("Restore", systemImage: "tray.and.arrow.up")
|
||||
} else {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
}
|
||||
.tint(currentState == .archived ? .blue : .orange)
|
||||
|
||||
Button {
|
||||
onToggleFavorite(bookmark)
|
||||
} label: {
|
||||
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
||||
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
||||
}
|
||||
.tint(bookmark.isMarked ? .gray : .pink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayoutView: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
CachedAsyncImage(url: imageURL)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.title)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if !bookmark.description.isEmpty {
|
||||
Text(bookmark.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if !bookmark.siteName.isEmpty {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "globe")
|
||||
Text(bookmark.siteName)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "clock")
|
||||
Text("\(readingTime) min")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var magazineLayoutView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AsyncImage(url: imageURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
} placeholder: {
|
||||
|
||||
Image(R.image.placeholder.name)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.if(namespace != nil) { view in
|
||||
view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!)
|
||||
}
|
||||
CachedAsyncImage(url: imageURL)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 140)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
||||
ZStack {
|
||||
@ -77,15 +235,12 @@ struct BookmarkCardView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
|
||||
// Published date
|
||||
if let publishedDate = formattedPublishedDate {
|
||||
HStack {
|
||||
Label(publishedDate, systemImage: "calendar")
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer() // show spacer only if we have the published Date
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||
@ -99,49 +254,102 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
||||
.onTapGesture {
|
||||
SafariUtil.openInSafari(url: bookmark.url)
|
||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button("Delete", role: .destructive) {
|
||||
onDelete(bookmark)
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
// Archive (left)
|
||||
Button {
|
||||
onArchive(bookmark)
|
||||
} label: {
|
||||
if currentState == .archived {
|
||||
Label("Restore", systemImage: "tray.and.arrow.up")
|
||||
} else {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var naturalLayoutView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CachedAsyncImage(url: imageURL)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: UIScreen.main.bounds.width - 32)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemBackground))
|
||||
.frame(width: 36, height: 36)
|
||||
Circle()
|
||||
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
|
||||
.frame(width: 32, height: 32)
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(bookmark.readProgress) / 100)
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 32, height: 32)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
Text("\(bookmark.readProgress)")
|
||||
.font(.caption2)
|
||||
.bold()
|
||||
Text("%")
|
||||
.font(.system(size: 8))
|
||||
.baselineOffset(2)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.tint(currentState == .archived ? .blue : .orange)
|
||||
|
||||
Button {
|
||||
onToggleFavorite(bookmark)
|
||||
} label: {
|
||||
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
||||
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.title)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
if let publishedDate = formattedPublishedDate {
|
||||
HStack {
|
||||
Label(publishedDate, systemImage: "calendar")
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||
Label("\(readingTime) min", systemImage: "clock")
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
if !bookmark.siteName.isEmpty {
|
||||
Label(bookmark.siteName, systemImage: "globe")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
||||
.onTapGesture {
|
||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.tint(bookmark.isMarked ? .gray : .pink)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
@ -156,13 +364,10 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
|
||||
|
||||
guard let date = formatter.date(from: published) else {
|
||||
// Fallback without milliseconds
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
guard let fallbackDate = formatter.date(from: published) else {
|
||||
return nil
|
||||
}
|
||||
@ -173,18 +378,19 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
// Today
|
||||
if calendar.isDateInToday(date) {
|
||||
if calendar.isDate(date, inSameDayAs: now) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return "Today, \(formatter.string(from: date))"
|
||||
}
|
||||
|
||||
// Yesterday
|
||||
if calendar.isDateInYesterday(date) {
|
||||
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
|
||||
calendar.isDate(date, inSameDayAs: yesterday) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return "Yesterday, \(formatter.string(from: date))"
|
||||
@ -211,13 +417,8 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
|
||||
private var imageURL: URL? {
|
||||
// Prioritize image, then thumbnail, then icon
|
||||
if let imageUrl = bookmark.resources.image?.src {
|
||||
return URL(string: imageUrl)
|
||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
||||
return URL(string: thumbnailUrl)
|
||||
} else if let iconUrl = bookmark.resources.icon?.src {
|
||||
return URL(string: iconUrl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -229,11 +430,9 @@ struct IconBadge: View {
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: systemName)
|
||||
.font(.caption2)
|
||||
.padding(6)
|
||||
.background(color.opacity(0.2))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 20, height: 20)
|
||||
.background(color)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,8 +4,6 @@ import SwiftUI
|
||||
|
||||
struct BookmarksView: View {
|
||||
|
||||
@Namespace private var namespace
|
||||
|
||||
// MARK: States
|
||||
|
||||
@State private var viewModel: BookmarksViewModel
|
||||
@ -14,7 +12,6 @@ struct BookmarksView: View {
|
||||
@State private var showingAddBookmarkFromShare = false
|
||||
@State private var shareURL = ""
|
||||
@State private var shareTitle = ""
|
||||
@State private var bookmarkToDelete: Bookmark? = nil
|
||||
|
||||
let state: BookmarkState
|
||||
let type: [BookmarkType]
|
||||
@ -39,14 +36,16 @@ struct BookmarksView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if shouldShowCenteredState {
|
||||
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
|
||||
skeletonLoadingView
|
||||
} else if shouldShowCenteredState {
|
||||
centeredStateView
|
||||
} else {
|
||||
bookmarksList
|
||||
}
|
||||
|
||||
// FAB Button - only show for "Unread" and when not in error/loading state
|
||||
if (state == .unread || state == .all) && !shouldShowCenteredState {
|
||||
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
|
||||
fabButton
|
||||
}
|
||||
}
|
||||
@ -56,8 +55,8 @@ struct BookmarksView: View {
|
||||
set: { selectedBookmarkId = $0 }
|
||||
)
|
||||
) { bookmarkId in
|
||||
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
||||
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
||||
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
}
|
||||
.sheet(isPresented: $showingAddBookmark) {
|
||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||
@ -68,18 +67,6 @@ struct BookmarksView: View {
|
||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||
}
|
||||
)
|
||||
.alert(item: $bookmarkToDelete) { bookmark in
|
||||
Alert(
|
||||
title: Text("Delete Bookmark"),
|
||||
message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await viewModel.deleteBookmark(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||
@ -102,7 +89,8 @@ struct BookmarksView: View {
|
||||
|
||||
private var shouldShowCenteredState: Bool {
|
||||
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
|
||||
return isEmpty && (viewModel.isLoading || viewModel.errorMessage != nil)
|
||||
let hasError = viewModel.errorMessage != nil
|
||||
return (isEmpty && viewModel.isLoading) || hasError
|
||||
}
|
||||
|
||||
// MARK: - View Components
|
||||
@ -148,16 +136,16 @@ struct BookmarksView: View {
|
||||
@ViewBuilder
|
||||
private func errorView(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
Image(systemName: viewModel.isNetworkError ? "wifi.slash" : "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.orange)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Unable to load bookmarks")
|
||||
Text(viewModel.isNetworkError ? "No internet connection" : "Unable to load bookmarks")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(message)
|
||||
Text(viewModel.isNetworkError ? "Please check your internet connection and try again" : message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@ -165,7 +153,7 @@ struct BookmarksView: View {
|
||||
|
||||
Button("Try Again") {
|
||||
Task {
|
||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||
await viewModel.retryLoading()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@ -179,6 +167,11 @@ struct BookmarksView: View {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
||||
Button(action: {
|
||||
// Don't navigate to detail if bookmark is pending deletion
|
||||
if viewModel.pendingDeletes[bookmark.id] != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if UIDevice.isPhone {
|
||||
selectedBookmarkId = bookmark.id
|
||||
} else {
|
||||
@ -195,20 +188,24 @@ struct BookmarksView: View {
|
||||
BookmarkCardView(
|
||||
bookmark: bookmark,
|
||||
currentState: state,
|
||||
layout: viewModel.cardLayoutStyle,
|
||||
pendingDelete: viewModel.pendingDeletes[bookmark.id],
|
||||
onArchive: { bookmark in
|
||||
Task {
|
||||
await viewModel.toggleArchive(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
onDelete: { bookmark in
|
||||
bookmarkToDelete = bookmark
|
||||
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
|
||||
},
|
||||
onToggleFavorite: { bookmark in
|
||||
Task {
|
||||
await viewModel.toggleFavorite(bookmark: bookmark)
|
||||
}
|
||||
},
|
||||
namespace: namespace
|
||||
onUndoDelete: { bookmarkId in
|
||||
viewModel.undoDelete(bookmarkId: bookmarkId)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
||||
@ -219,10 +216,14 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||
leading: 16,
|
||||
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||
trailing: 16
|
||||
))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
.matchedTransitionSource(id: bookmark.id, in: namespace)
|
||||
}
|
||||
|
||||
// Show loading indicator for pagination
|
||||
@ -256,6 +257,25 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var skeletonLoadingView: some View {
|
||||
ScrollView {
|
||||
SkeletonLoadingView(layout: viewModel.cardLayoutStyle)
|
||||
.padding(
|
||||
EdgeInsets(
|
||||
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||
leading: 16,
|
||||
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||
trailing: 16
|
||||
)
|
||||
)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.refreshable {
|
||||
await viewModel.refreshBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fabButton: some View {
|
||||
VStack {
|
||||
|
||||
@ -7,21 +7,31 @@ class BookmarksViewModel {
|
||||
private let getBooksmarksUseCase: PGetBookmarksUseCase
|
||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||
|
||||
var bookmarks: BookmarksPage?
|
||||
var isLoading = false
|
||||
var isInitialLoading = true
|
||||
var errorMessage: String?
|
||||
var isNetworkError = false
|
||||
var currentState: BookmarkState = .unread
|
||||
var currentType = [BookmarkType.article]
|
||||
var currentTag: String? = nil
|
||||
var cardLayoutStyle: CardLayoutStyle = .magazine
|
||||
|
||||
var showingAddBookmarkFromShare = false
|
||||
var shareURL = ""
|
||||
var shareTitle = ""
|
||||
|
||||
// Undo delete functionality
|
||||
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
||||
|
||||
// Prevent concurrent updates
|
||||
private var isUpdating = false
|
||||
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var limit = 20
|
||||
private var limit = 50
|
||||
private var offset = 0
|
||||
private var hasMoreData = true
|
||||
private var searchWorkItem: DispatchWorkItem?
|
||||
@ -36,13 +46,31 @@ class BookmarksViewModel {
|
||||
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
|
||||
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
|
||||
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||
|
||||
setupNotificationObserver()
|
||||
|
||||
Task {
|
||||
await loadCardLayout()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
// Listen for card layout changes
|
||||
NotificationCenter.default
|
||||
.publisher(for: NSNotification.Name("AddBookmarkFromShare"))
|
||||
.publisher(for: .cardLayoutChanged)
|
||||
.sink { notification in
|
||||
if let layout = notification.object as? CardLayoutStyle {
|
||||
Task { @MainActor in
|
||||
self.cardLayoutStyle = layout
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Listen for
|
||||
NotificationCenter.default
|
||||
.publisher(for: .addBookmarkFromShare)
|
||||
.sink { [weak self] notification in
|
||||
self?.handleShareNotification(notification)
|
||||
}
|
||||
@ -79,15 +107,19 @@ class BookmarksViewModel {
|
||||
|
||||
@MainActor
|
||||
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
|
||||
guard !isUpdating else { return }
|
||||
isUpdating = true
|
||||
defer { isUpdating = false }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
currentState = state
|
||||
currentType = type
|
||||
currentTag = tag
|
||||
|
||||
|
||||
offset = 0
|
||||
hasMoreData = true
|
||||
|
||||
|
||||
do {
|
||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||
state: state,
|
||||
@ -99,21 +131,38 @@ class BookmarksViewModel {
|
||||
)
|
||||
bookmarks = newBookmarks
|
||||
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available
|
||||
isNetworkError = false
|
||||
} catch {
|
||||
errorMessage = "Error loading bookmarks"
|
||||
// Check if it's a network error
|
||||
if let urlError = error as? URLError {
|
||||
switch urlError.code {
|
||||
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
|
||||
isNetworkError = true
|
||||
errorMessage = "No internet connection"
|
||||
default:
|
||||
isNetworkError = false
|
||||
errorMessage = "Error loading bookmarks"
|
||||
}
|
||||
} else {
|
||||
isNetworkError = false
|
||||
errorMessage = "Error loading bookmarks"
|
||||
}
|
||||
// Don't clear bookmarks on error - keep existing data visible
|
||||
}
|
||||
|
||||
|
||||
isLoading = false
|
||||
isInitialLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
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
|
||||
errorMessage = nil
|
||||
|
||||
|
||||
do {
|
||||
offset += limit // inc. offset
|
||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||
@ -126,9 +175,22 @@ class BookmarksViewModel {
|
||||
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
|
||||
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
|
||||
} catch {
|
||||
errorMessage = "Error loading more bookmarks"
|
||||
// Check if it's a network error
|
||||
if let urlError = error as? URLError {
|
||||
switch urlError.code {
|
||||
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
|
||||
isNetworkError = true
|
||||
errorMessage = "No internet connection"
|
||||
default:
|
||||
isNetworkError = false
|
||||
errorMessage = "Error loading more bookmarks"
|
||||
}
|
||||
} else {
|
||||
isNetworkError = false
|
||||
errorMessage = "Error loading more bookmarks"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@ -137,6 +199,13 @@ class BookmarksViewModel {
|
||||
await loadBookmarks(state: currentState)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func retryLoading() async {
|
||||
errorMessage = nil
|
||||
isNetworkError = false
|
||||
await loadBookmarks(state: currentState, type: currentType, tag: currentTag)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func toggleArchive(bookmark: Bookmark) async {
|
||||
do {
|
||||
@ -168,14 +237,101 @@ class BookmarksViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deleteBookmark(bookmark: Bookmark) async {
|
||||
func deleteBookmarkWithUndo(bookmark: Bookmark) {
|
||||
// Don't remove from UI immediately - just mark as pending
|
||||
let pendingDelete = PendingDelete(bookmark: bookmark)
|
||||
pendingDeletes[bookmark.id] = pendingDelete
|
||||
|
||||
// Start countdown timer for this specific delete
|
||||
startDeleteCountdown(for: bookmark.id)
|
||||
|
||||
// Schedule actual delete after 3 seconds
|
||||
let deleteTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||
|
||||
// Check if not cancelled and still pending
|
||||
if !Task.isCancelled, pendingDeletes[bookmark.id] != nil {
|
||||
await executeDelete(bookmark: bookmark)
|
||||
await MainActor.run {
|
||||
// Clean up
|
||||
pendingDeletes[bookmark.id]?.timer?.invalidate()
|
||||
pendingDeletes.removeValue(forKey: bookmark.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the task in the pending delete
|
||||
pendingDeletes[bookmark.id]?.deleteTask = deleteTask
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func undoDelete(bookmarkId: String) {
|
||||
guard let pendingDelete = pendingDeletes[bookmarkId] else { return }
|
||||
|
||||
// Cancel the delete task and timer
|
||||
pendingDelete.deleteTask?.cancel()
|
||||
pendingDelete.timer?.invalidate()
|
||||
|
||||
// Remove from pending deletes
|
||||
pendingDeletes.removeValue(forKey: bookmarkId)
|
||||
}
|
||||
|
||||
private func startDeleteCountdown(for bookmarkId: String) {
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self,
|
||||
let pendingDelete = self.pendingDeletes[bookmarkId] else {
|
||||
timer.invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
pendingDelete.progress += 1.0 / 30.0 // 3 seconds / 0.1 interval = 30 steps
|
||||
|
||||
// Trigger UI update by modifying the dictionary
|
||||
self.pendingDeletes[bookmarkId] = pendingDelete
|
||||
|
||||
if pendingDelete.progress >= 1.0 {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingDeletes[bookmarkId]?.timer = timer
|
||||
}
|
||||
|
||||
private func executeDelete(bookmark: Bookmark) async {
|
||||
do {
|
||||
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
||||
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
|
||||
|
||||
// If delete succeeds, remove bookmark from the list
|
||||
await MainActor.run {
|
||||
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Error deleting bookmark"
|
||||
await loadBookmarks(state: currentState)
|
||||
// If delete fails, restore the bookmark
|
||||
await MainActor.run {
|
||||
errorMessage = "Error deleting bookmark"
|
||||
if var currentBookmarks = bookmarks?.bookmarks {
|
||||
currentBookmarks.insert(bookmark, at: 0)
|
||||
bookmarks?.bookmarks = currentBookmarks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadCardLayout() async {
|
||||
cardLayoutStyle = await loadCardLayoutUseCase.execute()
|
||||
}
|
||||
}
|
||||
|
||||
class PendingDelete: Identifiable {
|
||||
let id = UUID()
|
||||
let bookmark: Bookmark
|
||||
var progress: Double = 0.0
|
||||
var timer: Timer?
|
||||
var deleteTask: Task<Void, Never>?
|
||||
|
||||
init(bookmark: Bookmark) {
|
||||
self.bookmark = bookmark
|
||||
}
|
||||
}
|
||||
|
||||
26
readeck/UI/Components/CachedAsyncImage.swift
Normal file
26
readeck/UI/Components/CachedAsyncImage.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct CachedAsyncImage: View {
|
||||
let url: URL?
|
||||
|
||||
init(url: URL?) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let url {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Color.gray.opacity(0.3)
|
||||
}
|
||||
.fade(duration: 0.25)
|
||||
.resizable()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Image("placeholder")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,9 +10,53 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Constants {
|
||||
struct Labels {
|
||||
static let pageSize = 12
|
||||
// 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,5 +1,65 @@
|
||||
// TODO: deprecated - This file is no longer used and can be removed
|
||||
// Replaced by CoreDataTagManagementView.swift which uses Core Data directly
|
||||
// instead of fetching labels via API
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let result = FlowResult(
|
||||
in: proposal.replacingUnspecifiedDimensions().width,
|
||||
subviews: subviews,
|
||||
spacing: spacing
|
||||
)
|
||||
return result.bounds
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = FlowResult(
|
||||
in: bounds.width,
|
||||
subviews: subviews,
|
||||
spacing: spacing
|
||||
)
|
||||
|
||||
for (index, subview) in subviews.enumerated() {
|
||||
subview.place(at: CGPoint(
|
||||
x: bounds.minX + result.frames[index].minX,
|
||||
y: bounds.minY + result.frames[index].minY
|
||||
), proposal: ProposedViewSize(result.frames[index].size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FlowResult {
|
||||
var frames: [CGRect] = []
|
||||
var bounds: CGSize = .zero
|
||||
|
||||
init(in maxWidth: CGFloat, subviews: LayoutSubviews, spacing: CGFloat) {
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var lineHeight: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
|
||||
if x + size.width > maxWidth && x > 0 {
|
||||
x = 0
|
||||
y += lineHeight + spacing
|
||||
lineHeight = 0
|
||||
}
|
||||
|
||||
frames.append(CGRect(x: x, y: y, width: size.width, height: size.height))
|
||||
lineHeight = max(lineHeight, size.height)
|
||||
x += size.width + spacing
|
||||
bounds.width = max(bounds.width, x - spacing)
|
||||
}
|
||||
|
||||
bounds.height = y + lineHeight
|
||||
}
|
||||
}
|
||||
|
||||
enum AddBookmarkFieldFocus {
|
||||
case url
|
||||
case labels
|
||||
@ -19,7 +79,7 @@ struct FocusModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
struct TagManagementView: View {
|
||||
struct LegacyTagManagementView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@ -27,7 +87,6 @@ struct TagManagementView: View {
|
||||
let selectedLabelsSet: Set<String>
|
||||
let searchText: Binding<String>
|
||||
let isLabelsLoading: Bool
|
||||
let availableLabelPages: [[BookmarkLabel]]
|
||||
let filteredLabels: [BookmarkLabel]
|
||||
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
||||
|
||||
@ -44,7 +103,6 @@ struct TagManagementView: View {
|
||||
selectedLabels: Set<String>,
|
||||
searchText: Binding<String>,
|
||||
isLabelsLoading: Bool,
|
||||
availableLabelPages: [[BookmarkLabel]],
|
||||
filteredLabels: [BookmarkLabel],
|
||||
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
||||
onAddCustomTag: @escaping () -> Void,
|
||||
@ -55,7 +113,6 @@ struct TagManagementView: View {
|
||||
self.selectedLabelsSet = selectedLabels
|
||||
self.searchText = searchText
|
||||
self.isLabelsLoading = isLabelsLoading
|
||||
self.availableLabelPages = availableLabelPages
|
||||
self.filteredLabels = filteredLabels
|
||||
self.searchFieldFocus = searchFieldFocus
|
||||
self.onAddCustomTag = onAddCustomTag
|
||||
@ -80,6 +137,7 @@ struct TagManagementView: View {
|
||||
.textFieldStyle(CustomTextFieldStyle())
|
||||
.keyboardType(.default)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.onSubmit {
|
||||
onAddCustomTag()
|
||||
}
|
||||
@ -138,7 +196,7 @@ struct TagManagementView: View {
|
||||
.scaleEffect(0.8)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 20)
|
||||
} else if availableLabelPages.isEmpty {
|
||||
} else if allLabels.isEmpty {
|
||||
VStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
@ -150,7 +208,7 @@ struct TagManagementView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
} else {
|
||||
labelsTabView
|
||||
labelsScrollView
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
@ -158,28 +216,47 @@ struct TagManagementView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var labelsTabView: some View {
|
||||
TabView {
|
||||
ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||
ForEach(labelsPage, id: \.id) { label in
|
||||
UnifiedLabelChip(
|
||||
label: label.name,
|
||||
isSelected: selectedLabelsSet.contains(label.name),
|
||||
isRemovable: false,
|
||||
onTap: {
|
||||
onToggleLabel(label.name)
|
||||
}
|
||||
)
|
||||
private var labelsScrollView: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ForEach(rowLabels, id: \.id) { label in
|
||||
UnifiedLabelChip(
|
||||
label: label.name,
|
||||
isSelected: false,
|
||||
isRemovable: false,
|
||||
onTap: {
|
||||
onToggleLabel(label.name)
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
|
||||
.frame(height: 180)
|
||||
.padding(.top, 10)
|
||||
.frame(height: calculateMaxHeight())
|
||||
}
|
||||
|
||||
private var chunkedLabels: [[BookmarkLabel]] {
|
||||
let maxRows = 3
|
||||
let labelsPerRow = max(1, availableUnselectedLabels.count / maxRows + (availableUnselectedLabels.count % maxRows > 0 ? 1 : 0))
|
||||
return availableUnselectedLabels.chunked(into: labelsPerRow)
|
||||
}
|
||||
|
||||
private var availableUnselectedLabels: [BookmarkLabel] {
|
||||
let labelsToShow = searchText.wrappedValue.isEmpty ? allLabels : filteredLabels
|
||||
return labelsToShow.filter { !selectedLabelsSet.contains($0.name) }
|
||||
}
|
||||
|
||||
private func calculateMaxHeight() -> CGFloat {
|
||||
// Berechne Höhe für maximal 3 Reihen
|
||||
let rowHeight: CGFloat = 32 // Höhe eines Labels
|
||||
let spacing: CGFloat = 8
|
||||
let maxRows: CGFloat = 3
|
||||
return (rowHeight * maxRows) + (spacing * (maxRows - 1))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -190,11 +267,11 @@ struct TagManagementView: View {
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||
FlowLayout(spacing: 8) {
|
||||
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
|
||||
UnifiedLabelChip(
|
||||
label: label,
|
||||
isSelected: false,
|
||||
isSelected: true,
|
||||
isRemovable: true,
|
||||
onTap: {
|
||||
// No action for selected labels
|
||||
@ -210,3 +287,11 @@ struct TagManagementView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array {
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
return stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0..<Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
717
readeck/UI/Components/NativeWebView.swift
Normal file
717
readeck/UI/Components/NativeWebView.swift
Normal file
@ -0,0 +1,717 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
// MARK: - iOS 26+ Native SwiftUI WebView Implementation
|
||||
// This implementation is available but not currently used
|
||||
// To activate: Replace WebView usage with hybrid approach using #available(iOS 26.0, *)
|
||||
|
||||
@available(iOS 26.0, *)
|
||||
struct NativeWebView: View {
|
||||
let htmlContent: String
|
||||
let settings: Settings
|
||||
let onHeightChange: (CGFloat) -> Void
|
||||
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()
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
WebKit.WebView(webPage)
|
||||
.scrollDisabled(true) // Disable internal scrolling
|
||||
.onAppear {
|
||||
loadStyledContent()
|
||||
setupAnnotationMessageHandler()
|
||||
setupScrollToPositionHandler()
|
||||
}
|
||||
.onChange(of: htmlContent) { _, _ in
|
||||
loadStyledContent()
|
||||
}
|
||||
.onChange(of: colorScheme) { _, _ in
|
||||
loadStyledContent()
|
||||
}
|
||||
.onChange(of: selectedAnnotationId) { _, _ in
|
||||
loadStyledContent()
|
||||
}
|
||||
.onChange(of: webPage.isLoading) { _, isLoading in
|
||||
if !isLoading {
|
||||
// Update height when content finishes loading
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
Task {
|
||||
await updateContentHeightWithJS()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
var lastHeight: CGFloat = 0
|
||||
|
||||
// Similar strategy to WebView: multiple attempts with increasing delays
|
||||
let delays = [0.1, 0.2, 0.5, 1.0, 1.5, 2.0] // 6 attempts like WebView
|
||||
|
||||
for (index, delay) in delays.enumerated() {
|
||||
let attempt = index + 1
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
|
||||
do {
|
||||
// Try to get height via JavaScript - use simple document.body.scrollHeight
|
||||
let result = try await webPage.callJavaScript("return document.body.scrollHeight")
|
||||
|
||||
if let height = result as? Double, height > 0 {
|
||||
let cgHeight = CGFloat(height)
|
||||
|
||||
// Update height if it's significantly different (> 5px like WebView)
|
||||
if lastHeight == 0 || abs(cgHeight - lastHeight) > 5 {
|
||||
print("🟢 NativeWebView - JavaScript height updated: \(height)px on attempt \(attempt)")
|
||||
DispatchQueue.main.async {
|
||||
self.onHeightChange(cgHeight)
|
||||
}
|
||||
lastHeight = cgHeight
|
||||
}
|
||||
|
||||
// If height seems stable (no change in last 2 attempts), we can exit early
|
||||
if attempt >= 2 && lastHeight > 0 {
|
||||
print("🟢 NativeWebView - Height stabilized at \(lastHeight)px after \(attempt) attempts")
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("🟡 NativeWebView - JavaScript attempt \(attempt) failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid height was found, use fallback
|
||||
if lastHeight == 0 {
|
||||
print("🔴 NativeWebView - No valid JavaScript height found, using fallback")
|
||||
updateContentHeightFallback()
|
||||
} else {
|
||||
print("🟢 NativeWebView - Final height: \(lastHeight)px")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateContentHeightFallback() {
|
||||
// Simplified fallback calculation
|
||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||
let plainText = htmlContent.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
let characterCount = plainText.count
|
||||
let estimatedLines = max(1, characterCount / 80)
|
||||
let textHeight = CGFloat(estimatedLines) * CGFloat(fontSize) * 1.8
|
||||
let finalHeight = max(400, min(textHeight + 100, 3000))
|
||||
|
||||
print("🟡 NativeWebView - Using fallback height: \(finalHeight)px")
|
||||
DispatchQueue.main.async {
|
||||
self.onHeightChange(finalHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadStyledContent() {
|
||||
let isDarkMode = colorScheme == .dark
|
||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
|
||||
|
||||
let styledHTML = """
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
|
||||
<style>
|
||||
* {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: \(fontFamily);
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: \(isDarkMode ? "#000000" : "#ffffff");
|
||||
color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
|
||||
font-size: \(fontSize)px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 { font-size: \(fontSize * 3 / 2)px; }
|
||||
h2 { font-size: \(fontSize * 5 / 4)px; }
|
||||
h3 { font-size: \(fontSize * 9 / 8)px; }
|
||||
|
||||
p { margin-bottom: 16px; }
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
a { color: \(isDarkMode ? "#0A84FF" : "#007AFF"); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid \(isDarkMode ? "#0A84FF" : "#007AFF");
|
||||
margin: 16px 0;
|
||||
padding: 12px 16px;
|
||||
font-style: italic;
|
||||
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)");
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
|
||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
|
||||
color: \(isDarkMode ? "#ffffff" : "#000000");
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
ul, ol { padding-left: 20px; margin-bottom: 16px; }
|
||||
li { margin-bottom: 4px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||||
th, td { border: 1px solid #ccc; padding: 8px 12px; text-align: left; }
|
||||
th { font-weight: 600; }
|
||||
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
\(htmlContent)
|
||||
<script>
|
||||
function measureHeight() {
|
||||
return Math.max(
|
||||
document.body.scrollHeight || 0,
|
||||
document.body.offsetHeight || 0,
|
||||
document.documentElement.clientHeight || 0,
|
||||
document.documentElement.scrollHeight || 0,
|
||||
document.documentElement.offsetHeight || 0
|
||||
);
|
||||
}
|
||||
|
||||
// Make function globally available
|
||||
window.getContentHeight = measureHeight;
|
||||
|
||||
// Auto-measure when everything is ready
|
||||
function scheduleHeightCheck() {
|
||||
// Multiple timing strategies
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', delayedHeightCheck);
|
||||
} else {
|
||||
delayedHeightCheck();
|
||||
}
|
||||
|
||||
// Also check after images load
|
||||
window.addEventListener('load', delayedHeightCheck);
|
||||
|
||||
// Force check after layout
|
||||
setTimeout(delayedHeightCheck, 50);
|
||||
setTimeout(delayedHeightCheck, 100);
|
||||
setTimeout(delayedHeightCheck, 200);
|
||||
setTimeout(delayedHeightCheck, 500);
|
||||
}
|
||||
|
||||
function delayedHeightCheck() {
|
||||
// Force layout recalculation
|
||||
document.body.offsetHeight;
|
||||
const height = measureHeight();
|
||||
console.log('NativeWebView height check:', height);
|
||||
}
|
||||
|
||||
scheduleHeightCheck();
|
||||
|
||||
// Scroll to selected annotation
|
||||
\(generateScrollToAnnotationJS())
|
||||
|
||||
// Text Selection and Annotation Overlay
|
||||
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
webPage.load(html: styledHTML)
|
||||
|
||||
// Update height after content loads
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
Task {
|
||||
await updateContentHeightWithJS()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getFontSize(from fontSize: FontSize) -> Int {
|
||||
switch fontSize {
|
||||
case .small: return 14
|
||||
case .medium: return 16
|
||||
case .large: return 18
|
||||
case .extraLarge: return 20
|
||||
}
|
||||
}
|
||||
|
||||
private func getFontFamily(from fontFamily: FontFamily) -> String {
|
||||
switch fontFamily {
|
||||
case .system: return "-apple-system, BlinkMacSystemFont, sans-serif"
|
||||
case .serif: return "'Times New Roman', Times, serif"
|
||||
case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif"
|
||||
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)
|
||||
// This would be the implementation to use both native and legacy WebViews
|
||||
// Currently commented out - the app uses only the crash-resistant WebView
|
||||
|
||||
/*
|
||||
struct HybridWebView: View {
|
||||
let htmlContent: String
|
||||
let settings: Settings
|
||||
let onHeightChange: (CGFloat) -> Void
|
||||
var onScroll: ((Double) -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
// Use new native SwiftUI WebView on iOS 26+
|
||||
NativeWebView(
|
||||
htmlContent: htmlContent,
|
||||
settings: settings,
|
||||
onHeightChange: onHeightChange,
|
||||
onScroll: onScroll
|
||||
)
|
||||
} else {
|
||||
// Fallback to crash-resistant WebView for older iOS
|
||||
WebView(
|
||||
htmlContent: htmlContent,
|
||||
settings: settings,
|
||||
onHeightChange: onHeightChange,
|
||||
onScroll: onScroll
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
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)
|
||||
}
|
||||
176
readeck/UI/Components/SkeletonLoadingView.swift
Normal file
176
readeck/UI/Components/SkeletonLoadingView.swift
Normal file
@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SkeletonLoadingView: View {
|
||||
let layout: CardLayoutStyle
|
||||
@State private var animateGradient = false
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(spacing: layout == .compact ? 8 : 12) {
|
||||
ForEach(0..<6, id: \.self) { _ in
|
||||
skeletonCard
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
animateGradient = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var skeletonCard: some View {
|
||||
switch layout {
|
||||
case .compact:
|
||||
compactSkeletonCard
|
||||
case .magazine:
|
||||
magazineSkeletonCard
|
||||
case .natural:
|
||||
naturalSkeletonCard
|
||||
}
|
||||
}
|
||||
|
||||
private var compactSkeletonCard: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
// Image placeholder
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Title placeholder
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 16)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 180, height: 16)
|
||||
|
||||
// Description placeholder
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 14)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 120, height: 14)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom info placeholder
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 80, height: 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 50, height: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var magazineSkeletonCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Image placeholder
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 140)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Title placeholder
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 16)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 200, height: 16)
|
||||
|
||||
// Info placeholders
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 80, height: 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 60, height: 12)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var naturalSkeletonCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Image placeholder
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(shimmerGradient)
|
||||
.frame(minHeight: 180)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Title placeholder
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(height: 16)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 220, height: 16)
|
||||
|
||||
// Info placeholders
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 90, height: 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 70, height: 12)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
|
||||
}
|
||||
|
||||
private var shimmerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.3),
|
||||
Color.gray.opacity(0.1),
|
||||
Color.gray.opacity(0.3)
|
||||
],
|
||||
startPoint: animateGradient ? .topLeading : .topTrailing,
|
||||
endPoint: animateGradient ? .bottomTrailing : .bottomLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ScrollView {
|
||||
SkeletonLoadingView(layout: .magazine)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
68
readeck/UI/Components/UndoToastView.swift
Normal file
68
readeck/UI/Components/UndoToastView.swift
Normal file
@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UndoToastView: View {
|
||||
let bookmarkTitle: String
|
||||
let progress: Double
|
||||
let onUndo: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Bookmark deleted")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(bookmarkTitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Undo") {
|
||||
onUndo()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.2))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.black.opacity(0.85))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
// Progress bar at bottom
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: .white.opacity(0.8)))
|
||||
.scaleEffect(y: 0.5)
|
||||
}
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
Spacer()
|
||||
UndoToastView(
|
||||
bookmarkTitle: "How to Build Great Products",
|
||||
progress: 0.6,
|
||||
onUndo: {}
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.background(Color.gray.opacity(0.3))
|
||||
}
|
||||
@ -6,6 +6,9 @@ struct WebView: UIViewRepresentable {
|
||||
let settings: Settings
|
||||
let onHeightChange: (CGFloat) -> Void
|
||||
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
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
@ -21,31 +24,50 @@ struct WebView: UIViewRepresentable {
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = UIColor.clear
|
||||
|
||||
|
||||
// Allow text selection and copying
|
||||
webView.allowsBackForwardNavigationGestures = false
|
||||
webView.allowsLinkPreview = true
|
||||
|
||||
// Message Handler hier einmalig hinzufügen
|
||||
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||
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.onScroll = onScroll
|
||||
|
||||
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||
context.coordinator.onScrollToPosition = onScrollToPosition
|
||||
context.coordinator.webView = webView
|
||||
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
|
||||
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||
context.coordinator.onScrollToPosition = onScrollToPosition
|
||||
|
||||
let isDarkMode = colorScheme == .dark
|
||||
|
||||
// Font Settings aus Settings-Objekt
|
||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
|
||||
|
||||
|
||||
// Clean up problematic HTML that kills performance
|
||||
let cleanedHTML = htmlContent
|
||||
// Remove Google attributes that cause navigation events
|
||||
.replacingOccurrences(of: #"\s*jsaction="[^"]*""#, with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"\s*jscontroller="[^"]*""#, with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"\s*jsname="[^"]*""#, with: "", options: .regularExpression)
|
||||
// Remove unnecessary IDs that bloat the DOM
|
||||
.replacingOccurrences(of: #"\s*id="[^"]*""#, with: "", options: .regularExpression)
|
||||
// Remove tabindex from non-interactive elements
|
||||
.replacingOccurrences(of: #"\s*tabindex="[^"]*""#, with: "", options: .regularExpression)
|
||||
// Remove role=button from figures (causes false click targets)
|
||||
.replacingOccurrences(of: #"\s*role="button""#, with: "", options: .regularExpression)
|
||||
// Fix invalid nested <p> tags inside <pre><span>
|
||||
.replacingOccurrences(of: #"<pre><span[^>]*>([^<]*)<p>"#, with: "<pre><span>$1\n", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"</p>([^<]*)</span></pre>"#, with: "\n$1</span></pre>", options: .regularExpression)
|
||||
|
||||
let styledHTML = """
|
||||
<html>
|
||||
<head>
|
||||
@ -223,30 +245,84 @@ struct WebView: UIViewRepresentable {
|
||||
--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>
|
||||
</head>
|
||||
<body>
|
||||
\(htmlContent)
|
||||
\(cleanedHTML)
|
||||
<script>
|
||||
let lastHeight = 0;
|
||||
let heightUpdateTimeout = null;
|
||||
let scrollTimeout = null;
|
||||
let isScrolling = false;
|
||||
|
||||
function updateHeight() {
|
||||
const height = document.body.scrollHeight;
|
||||
window.webkit.messageHandlers.heightUpdate.postMessage(height);
|
||||
if (Math.abs(height - lastHeight) > 5 && !isScrolling) {
|
||||
lastHeight = height;
|
||||
window.webkit.messageHandlers.heightUpdate.postMessage(height);
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedHeightUpdate() {
|
||||
clearTimeout(heightUpdateTimeout);
|
||||
heightUpdateTimeout = setTimeout(updateHeight, 100);
|
||||
}
|
||||
|
||||
window.addEventListener('load', updateHeight);
|
||||
setTimeout(updateHeight, 500);
|
||||
|
||||
// Höhe bei Bild-Ladevorgängen aktualisieren
|
||||
document.querySelectorAll('img').forEach(img => {
|
||||
img.addEventListener('load', updateHeight);
|
||||
});
|
||||
// Scroll progress reporting
|
||||
window.addEventListener('scroll', function() {
|
||||
var scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
var docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||||
var progress = docHeight > 0 ? scrollTop / docHeight : 0;
|
||||
window.webkit.messageHandlers.scrollProgress.postMessage(progress);
|
||||
img.addEventListener('load', debouncedHeightUpdate);
|
||||
});
|
||||
|
||||
// Scroll to selected annotation
|
||||
\(generateScrollToAnnotationJS())
|
||||
|
||||
// Text Selection and Annotation Overlay
|
||||
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -254,6 +330,17 @@ struct WebView: UIViewRepresentable {
|
||||
webView.loadHTMLString(styledHTML, baseURL: nil)
|
||||
}
|
||||
|
||||
func dismantleUIView(_ webView: WKWebView, coordinator: WebViewCoordinator) {
|
||||
webView.stopLoading()
|
||||
webView.navigationDelegate = nil
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition")
|
||||
webView.loadHTMLString("", baseURL: nil)
|
||||
coordinator.cleanup()
|
||||
}
|
||||
|
||||
func makeCoordinator() -> WebViewCoordinator {
|
||||
WebViewCoordinator()
|
||||
}
|
||||
@ -279,12 +366,321 @@ struct WebView: UIViewRepresentable {
|
||||
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 {
|
||||
// Callbacks
|
||||
var onHeightChange: ((CGFloat) -> Void)?
|
||||
var onScroll: ((Double) -> Void)?
|
||||
var hasHeightUpdate: Bool = false
|
||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
|
||||
var onScrollToPosition: ((CGFloat) -> Void)?
|
||||
|
||||
// WebView reference
|
||||
weak var webView: WKWebView?
|
||||
|
||||
// Height management
|
||||
var lastHeight: CGFloat = 0
|
||||
var pendingHeight: CGFloat = 0
|
||||
var heightUpdateTimer: Timer?
|
||||
|
||||
// Scroll management
|
||||
var isScrolling: Bool = false
|
||||
var scrollVelocity: Double = 0
|
||||
var lastScrollTime: Date = Date()
|
||||
var scrollEndTimer: Timer?
|
||||
|
||||
// Lifecycle
|
||||
private var isCleanedUp = false
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
@ -300,16 +696,106 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
if message.name == "heightUpdate", let height = message.body as? CGFloat {
|
||||
DispatchQueue.main.async {
|
||||
if self.hasHeightUpdate == false {
|
||||
self.onHeightChange?(height)
|
||||
self.hasHeightUpdate = true
|
||||
}
|
||||
self.handleHeightUpdate(height: height)
|
||||
}
|
||||
}
|
||||
if message.name == "scrollProgress", let progress = message.body as? Double {
|
||||
DispatchQueue.main.async {
|
||||
self.onScroll?(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) {
|
||||
// Store the pending height
|
||||
pendingHeight = height
|
||||
|
||||
// If we're actively scrolling, defer the height update
|
||||
if isScrolling {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply height update immediately if not scrolling
|
||||
applyHeightUpdate(height: height)
|
||||
}
|
||||
|
||||
private func handleScrollProgress(progress: Double) {
|
||||
let now = Date()
|
||||
let timeDelta = now.timeIntervalSince(lastScrollTime)
|
||||
|
||||
// Calculate scroll velocity to detect fast scrolling
|
||||
if timeDelta > 0 {
|
||||
scrollVelocity = abs(progress) / timeDelta
|
||||
}
|
||||
|
||||
lastScrollTime = now
|
||||
isScrolling = true
|
||||
|
||||
// Longer delay for scroll end detection, especially during fast scrolling
|
||||
let scrollEndDelay: TimeInterval = scrollVelocity > 2.0 ? 0.8 : 0.5
|
||||
|
||||
scrollEndTimer?.invalidate()
|
||||
scrollEndTimer = Timer.scheduledTimer(withTimeInterval: scrollEndDelay, repeats: false) { [weak self] _ in
|
||||
self?.handleScrollEnd()
|
||||
}
|
||||
|
||||
onScroll?(progress)
|
||||
}
|
||||
|
||||
private func handleScrollEnd() {
|
||||
isScrolling = false
|
||||
scrollVelocity = 0
|
||||
|
||||
// Apply any pending height update after scrolling ends
|
||||
if pendingHeight != lastHeight && pendingHeight > 0 {
|
||||
// Add small delay to ensure scroll has fully stopped
|
||||
heightUpdateTimer?.invalidate()
|
||||
heightUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.applyHeightUpdate(height: self.pendingHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyHeightUpdate(height: CGFloat) {
|
||||
// Only update if height actually changed significantly
|
||||
let heightDifference = abs(height - lastHeight)
|
||||
if heightDifference < 5 { // Ignore tiny height changes that cause flicker
|
||||
return
|
||||
}
|
||||
|
||||
lastHeight = height
|
||||
onHeightChange?(height)
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
guard !isCleanedUp else { return }
|
||||
isCleanedUp = true
|
||||
|
||||
scrollEndTimer?.invalidate()
|
||||
scrollEndTimer = nil
|
||||
heightUpdateTimer?.invalidate()
|
||||
heightUpdateTimer = nil
|
||||
|
||||
onHeightChange = 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,8 +16,15 @@ protocol UseCaseFactory {
|
||||
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
|
||||
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||
func makeCreateLabelUseCase() -> PCreateLabelUseCase
|
||||
func makeSyncTagsUseCase() -> PSyncTagsUseCase
|
||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
||||
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
|
||||
}
|
||||
|
||||
|
||||
@ -28,9 +35,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
||||
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
||||
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()
|
||||
|
||||
|
||||
private init() {}
|
||||
|
||||
func makeLoginUseCase() -> PLoginUseCase {
|
||||
@ -94,7 +104,19 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
let labelsRepository = LabelsRepository(api: api)
|
||||
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 {
|
||||
return AddTextToSpeechQueueUseCase()
|
||||
}
|
||||
@ -102,4 +124,24 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
|
||||
return OfflineBookmarkSyncUseCase()
|
||||
}
|
||||
|
||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
|
||||
return LoadCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||
}
|
||||
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||
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
|
||||
|
||||
class MockUseCaseFactory: UseCaseFactory {
|
||||
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
|
||||
MockCheckServerReachabilityUseCase()
|
||||
}
|
||||
|
||||
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
||||
MockOfflineBookmarkSyncUseCase()
|
||||
}
|
||||
@ -72,11 +76,34 @@ class MockUseCaseFactory: UseCaseFactory {
|
||||
func makeGetLabelsUseCase() -> any PGetLabelsUseCase {
|
||||
MockGetLabelsUseCase()
|
||||
}
|
||||
|
||||
|
||||
func makeCreateLabelUseCase() -> any PCreateLabelUseCase {
|
||||
MockCreateLabelUseCase()
|
||||
}
|
||||
|
||||
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
|
||||
MockSyncTagsUseCase()
|
||||
}
|
||||
|
||||
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
|
||||
MockAddTextToSpeechQueueUseCase()
|
||||
}
|
||||
|
||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
|
||||
MockLoadCardLayoutUseCase()
|
||||
}
|
||||
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||
MockSaveCardLayoutUseCase()
|
||||
}
|
||||
|
||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||
MockGetBookmarkAnnotationsUseCase()
|
||||
}
|
||||
|
||||
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
|
||||
MockDeleteAnnotationUseCase()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -106,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 {
|
||||
func execute(search: String) async throws -> BookmarksPage {
|
||||
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)
|
||||
@ -143,6 +182,7 @@ class MockSaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {}
|
||||
func execute(enableTTS: Bool) async throws {}
|
||||
func execute(theme: Theme) async throws {}
|
||||
func execute(urlOpener: UrlOpener) async throws {}
|
||||
}
|
||||
|
||||
class MockGetBookmarkUseCase: PGetBookmarkUseCase {
|
||||
@ -204,6 +244,42 @@ class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
class MockLoadCardLayoutUseCase: PLoadCardLayoutUseCase {
|
||||
func execute() async -> CardLayoutStyle {
|
||||
return .magazine
|
||||
}
|
||||
}
|
||||
|
||||
class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
|
||||
func execute(layout: CardLayoutStyle) async {
|
||||
// Mock implementation - do nothing
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
// Created by Ilyas Hallak on 01.07.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum BookmarkState: String, CaseIterable {
|
||||
case all = "all"
|
||||
case unread = "unread"
|
||||
@ -14,13 +16,13 @@ enum BookmarkState: String, CaseIterable {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return "All"
|
||||
return NSLocalizedString("All", comment: "")
|
||||
case .unread:
|
||||
return "Unread"
|
||||
return NSLocalizedString("Unread", comment: "")
|
||||
case .favorite:
|
||||
return "Favorites"
|
||||
return NSLocalizedString("Favorites", comment: "")
|
||||
case .archived:
|
||||
return "Archive"
|
||||
return NSLocalizedString("Archive", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ class OfflineBookmarksViewModel {
|
||||
private let successDelaySubject = PassthroughSubject<Int, Never>()
|
||||
private var completionTimerActive = false
|
||||
|
||||
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
|
||||
self.syncUseCase = syncUseCase
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase()
|
||||
setupBindings()
|
||||
refreshState()
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ struct PadSidebarView: View {
|
||||
@State private var selectedTag: BookmarkLabel?
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@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]
|
||||
|
||||
@ -87,11 +87,11 @@ struct PadSidebarView: View {
|
||||
case .all:
|
||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||
case .unread:
|
||||
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
|
||||
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||
case .favorite:
|
||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
|
||||
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||
case .archived:
|
||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
|
||||
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .article:
|
||||
@ -103,12 +103,12 @@ struct PadSidebarView: View {
|
||||
case .tags:
|
||||
NavigationStack {
|
||||
LabelsView(selectedTag: $selectedTag)
|
||||
.navigationDestination(item: $selectedTag) { label in
|
||||
BookmarksView(state: .all, type: [], selectedBookmark: $selectedBookmark, tag: label.name)
|
||||
.navigationTitle("\(label.name) (\(label.count))")
|
||||
.onDisappear {
|
||||
selectedTag = nil
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $selectedTag) { label in
|
||||
BookmarksView(state: .all, type: [], selectedBookmark: $selectedBookmark, tag: label.name)
|
||||
.navigationTitle("\(label.name) (\(label.count))")
|
||||
.onDisappear {
|
||||
selectedTag = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,61 +9,184 @@ import SwiftUI
|
||||
|
||||
struct PhoneTabView: View {
|
||||
private let mainTabs: [SidebarTab] = [.all, .unread, .favorite, .archived]
|
||||
private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings]
|
||||
|
||||
@State private var selectedMoreTab: SidebarTab? = nil
|
||||
@State private var selectedTabIndex: Int = 1
|
||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
||||
|
||||
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
||||
|
||||
@State private var selectedTab: SidebarTab = .unread
|
||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
|
||||
|
||||
// Navigation paths for each tab
|
||||
@State private var allPath = NavigationPath()
|
||||
@State private var unreadPath = NavigationPath()
|
||||
@State private var favoritePath = NavigationPath()
|
||||
@State private var archivedPath = NavigationPath()
|
||||
@State private var morePath = NavigationPath()
|
||||
|
||||
// Search functionality
|
||||
@State private var searchViewModel = SearchBookmarksViewModel()
|
||||
@FocusState private var searchFieldIsFocused: Bool
|
||||
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
|
||||
private var cardLayoutStyle: CardLayoutStyle {
|
||||
appSettings.settings?.cardLayoutStyle ?? .compact
|
||||
}
|
||||
|
||||
private var offlineBookmarksBadgeCount: Int {
|
||||
offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GlobalPlayerContainerView {
|
||||
TabView(selection: $selectedTabIndex) {
|
||||
mainTabsContent
|
||||
moreTabContent
|
||||
TabView(selection: $selectedTab) {
|
||||
|
||||
Tab(value: SidebarTab.all) {
|
||||
NavigationStack(path: $allPath) {
|
||||
tabView(for: .all)
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarTab.all.label, systemImage: SidebarTab.all.systemImage)
|
||||
}
|
||||
|
||||
Tab(value: SidebarTab.unread) {
|
||||
NavigationStack(path: $unreadPath) {
|
||||
tabView(for: .unread)
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarTab.unread.label, systemImage: SidebarTab.unread.systemImage)
|
||||
}
|
||||
|
||||
Tab(value: SidebarTab.favorite) {
|
||||
NavigationStack(path: $favoritePath) {
|
||||
tabView(for: .favorite)
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarTab.favorite.label, systemImage: SidebarTab.favorite.systemImage)
|
||||
}
|
||||
|
||||
Tab(value: SidebarTab.archived) {
|
||||
NavigationStack(path: $archivedPath) {
|
||||
tabView(for: .archived)
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarTab.archived.label, systemImage: SidebarTab.archived.systemImage)
|
||||
}
|
||||
|
||||
// iOS 26+: Dedicated search tab with role
|
||||
if #available(iOS 26, *) {
|
||||
Tab("Search", systemImage: SidebarTab.search.systemImage, value: SidebarTab.search, role: .search) {
|
||||
NavigationStack {
|
||||
moreTabContent
|
||||
.searchable(text: $searchViewModel.searchQuery, prompt: "Search bookmarks...")
|
||||
}
|
||||
}
|
||||
.badge(offlineBookmarksBadgeCount)
|
||||
} else {
|
||||
Tab(value: SidebarTab.settings) {
|
||||
NavigationStack(path: $morePath) {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
// Classic search bar for iOS 18
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.gray)
|
||||
TextField("Search...", text: $searchViewModel.searchQuery)
|
||||
.focused($searchFieldIsFocused)
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
if !searchViewModel.searchQuery.isEmpty {
|
||||
Button(action: {
|
||||
searchViewModel.searchQuery = ""
|
||||
searchFieldIsFocused = true
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
.padding([.horizontal, .top])
|
||||
|
||||
moreTabContent
|
||||
moreTabsFooter
|
||||
}
|
||||
.navigationTitle("More")
|
||||
}
|
||||
} label: {
|
||||
Label("More", systemImage: "ellipsis")
|
||||
}
|
||||
.badge(offlineBookmarksBadgeCount)
|
||||
}
|
||||
}
|
||||
.tabBarMinimizeBehaviorIfAvailable()
|
||||
.accentColor(.accentColor)
|
||||
.searchToolbarBehaviorIfAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// MARK: - Tab Content
|
||||
|
||||
@ViewBuilder
|
||||
private var mainTabsContent: some View {
|
||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||
NavigationStack {
|
||||
tabView(for: tab)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.tag(idx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var moreTabContent: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
moreTabsList
|
||||
moreTabsFooter
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis")
|
||||
}
|
||||
.badge(offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0)
|
||||
.tag(mainTabs.count)
|
||||
.onAppear {
|
||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||
selectedMoreTab = nil
|
||||
}
|
||||
if searchViewModel.searchQuery.isEmpty {
|
||||
moreTabsList
|
||||
} else {
|
||||
searchResultsView
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var searchResultsView: some View {
|
||||
if searchViewModel.isLoading {
|
||||
ProgressView("Searching...")
|
||||
.padding()
|
||||
} else if let error = searchViewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.padding()
|
||||
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
||||
List(bookmarks) { bookmark in
|
||||
ZStack {
|
||||
|
||||
// Hidden NavigationLink to remove disclosure indicator
|
||||
NavigationLink {
|
||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
|
||||
BookmarkCardView(
|
||||
bookmark: bookmark,
|
||||
currentState: .all,
|
||||
layout: cardLayoutStyle,
|
||||
onArchive: { _ in },
|
||||
onDelete: { _ in },
|
||||
onToggleFavorite: { _ in }
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: cardLayoutStyle == .compact ? 8 : 12,
|
||||
leading: 16,
|
||||
bottom: cardLayoutStyle == .compact ? 8 : 12,
|
||||
trailing: 16
|
||||
))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
.listStyle(.plain)
|
||||
} else if searchViewModel.searchQuery.isEmpty == false {
|
||||
ContentUnavailableView("No results", systemImage: "magnifyingglass", description: Text("No bookmarks found."))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var moreTabsList: some View {
|
||||
List {
|
||||
@ -71,12 +194,7 @@ struct PhoneTabView: View {
|
||||
NavigationLink {
|
||||
tabView(for: tab)
|
||||
.navigationTitle(tab.label)
|
||||
.onDisappear {
|
||||
// tags and search handle navigation by own
|
||||
if tab != .tags && tab != .search {
|
||||
selectedMoreTab = nil
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
@ -116,13 +234,13 @@ struct PhoneTabView: View {
|
||||
case .all:
|
||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||
case .unread:
|
||||
BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil))
|
||||
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||
case .favorite:
|
||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
|
||||
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||
case .archived:
|
||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
|
||||
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||
case .search:
|
||||
SearchBookmarksView(selectedBookmark: .constant(nil))
|
||||
EmptyView() // search is directly implemented
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .article:
|
||||
@ -136,3 +254,28 @@ struct PhoneTabView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - View Extension for iOS 26+ Compatibility
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func searchToolbarBehaviorIfAvailable() -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
self
|
||||
.searchToolbarBehavior(.minimize)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func tabBarMinimizeBehaviorIfAvailable() -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
self
|
||||
.tabBarMinimizeBehavior(.onScrollDown)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
// Created by Ilyas Hallak on 01.07.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
||||
case search, all, unread, favorite, archived, article, videos, pictures, tags, settings
|
||||
|
||||
@ -12,16 +14,16 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: return "All"
|
||||
case .unread: return "Unread"
|
||||
case .favorite: return "Favorites"
|
||||
case .archived: return "Archive"
|
||||
case .search: return "Search"
|
||||
case .settings: return "Settings"
|
||||
case .article: return "Articles"
|
||||
case .videos: return "Videos"
|
||||
case .pictures: return "Pictures"
|
||||
case .tags: return "Tags"
|
||||
case .all: return NSLocalizedString("All", comment: "")
|
||||
case .unread: return NSLocalizedString("Unread", comment: "")
|
||||
case .favorite: return NSLocalizedString("Favorites", comment: "")
|
||||
case .archived: return NSLocalizedString("Archive", comment: "")
|
||||
case .search: return NSLocalizedString("Search", comment: "")
|
||||
case .settings: return NSLocalizedString("Settings", comment: "")
|
||||
case .article: return NSLocalizedString("Articles", comment: "")
|
||||
case .videos: return NSLocalizedString("Videos", comment: "")
|
||||
case .pictures: return NSLocalizedString("Pictures", comment: "")
|
||||
case .tags: return NSLocalizedString("Tags", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,21 +5,37 @@ struct MainTabView: View {
|
||||
@State private var selectedTab: SidebarTab = .unread
|
||||
@State var selectedBookmark: Bookmark?
|
||||
@StateObject private var playerUIState = PlayerUIState()
|
||||
|
||||
@State private var showReleaseNotes = false
|
||||
|
||||
// sizeClass
|
||||
@Environment(\.horizontalSizeClass)
|
||||
var horizontalSizeClass
|
||||
|
||||
|
||||
@Environment(\.verticalSizeClass)
|
||||
var verticalSizeClass
|
||||
|
||||
|
||||
var body: some View {
|
||||
if UIDevice.isPhone {
|
||||
PhoneTabView()
|
||||
.environmentObject(playerUIState)
|
||||
} else {
|
||||
PadSidebarView()
|
||||
.environmentObject(playerUIState)
|
||||
Group {
|
||||
if UIDevice.isPhone {
|
||||
PhoneTabView()
|
||||
.environmentObject(playerUIState)
|
||||
} else {
|
||||
PadSidebarView()
|
||||
.environmentObject(playerUIState)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showReleaseNotes) {
|
||||
ReleaseNotesView()
|
||||
}
|
||||
.onAppear {
|
||||
checkForNewVersion()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkForNewVersion() {
|
||||
if VersionManager.shared.isNewVersion {
|
||||
showReleaseNotes = true
|
||||
VersionManager.shared.markVersionAsSeen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,15 +18,23 @@ import Combine
|
||||
|
||||
class AppSettings: ObservableObject {
|
||||
@Published var settings: Settings?
|
||||
|
||||
|
||||
var enableTTS: Bool {
|
||||
settings?.enableTTS ?? false
|
||||
}
|
||||
|
||||
|
||||
var theme: Theme {
|
||||
settings?.theme ?? .system
|
||||
}
|
||||
|
||||
var urlOpener: UrlOpener {
|
||||
settings?.urlOpener ?? .inAppBrowser
|
||||
}
|
||||
|
||||
var tagSortOrder: TagSortOrder {
|
||||
settings?.tagSortOrder ?? .byCount
|
||||
}
|
||||
|
||||
init(settings: Settings? = nil) {
|
||||
self.settings = settings
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
134
readeck/UI/Resources/RELEASE_NOTES.md
Normal file
134
readeck/UI/Resources/RELEASE_NOTES.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Release Notes
|
||||
|
||||
Thanks for using the Readeck iOS app! Below are the release notes for each version.
|
||||
|
||||
**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.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.
|
||||
|
||||
### Modern Reading Experience (iOS 26+)
|
||||
|
||||
- **Completely rebuilt article view** for the latest iOS version
|
||||
- Smoother scrolling and faster page loading
|
||||
- Better battery life and memory usage
|
||||
- Native iOS integration for the best experience
|
||||
|
||||
### Quick Actions
|
||||
|
||||
- **Smart action buttons** appear automatically when you're almost done reading
|
||||
- Beautiful, modern design that blends with your content
|
||||
- Quickly favorite or archive articles without scrolling back up
|
||||
- Buttons fade away elegantly when you scroll back
|
||||
- Your progress bar now reflects the entire article length
|
||||
|
||||
### Beautiful Article Images
|
||||
|
||||
- **Article header images now display properly** without awkward cropping
|
||||
- Full images with a subtle blurred background
|
||||
- Tap to view images in full screen
|
||||
|
||||
### Smoother Performance
|
||||
|
||||
- **Dramatically improved scrolling** - no more stuttering or lag
|
||||
- Faster article loading times
|
||||
- Better handling of long articles with many images
|
||||
- Overall snappier app experience
|
||||
|
||||
### Open Links Your Way
|
||||
|
||||
- **Choose your preferred browser** for opening links
|
||||
- Open in Safari or in-app browser
|
||||
- Thanks to christian-putzke for this contribution!
|
||||
|
||||
### Fixes & Improvements
|
||||
|
||||
- Articles no longer overflow the screen width
|
||||
- Fixed spacing issues in article view
|
||||
- Improved progress calculation accuracy
|
||||
- Better handling of article content
|
||||
- Fixed issues with label names containing spaces
|
||||
|
||||
---
|
||||
|
||||
## Version 1.0 (Initial Release)
|
||||
|
||||
### Core Features
|
||||
|
||||
- Browse and read saved articles
|
||||
- Bookmark management with labels
|
||||
- Full article view with custom fonts
|
||||
- Text-to-speech support (Beta)
|
||||
- Archive and favorite functionality
|
||||
- Choose different Layouts (Compact, Magazine, Natural)
|
||||
|
||||
### Reading Experience
|
||||
|
||||
- Clean, distraction-free reading interface
|
||||
- Customizable font settings
|
||||
- Header Image viewer with zoom support
|
||||
- Progress tracking per article
|
||||
- Dark mode support
|
||||
|
||||
### Organization
|
||||
|
||||
- Label system for categorization (multi-select)
|
||||
- Search
|
||||
- Archive completed articles
|
||||
- Jump to last read position
|
||||
|
||||
### Share Extension
|
||||
|
||||
- Save articles from other apps
|
||||
- Quick access to save and label bookmarks
|
||||
- Save Bookmarks offline if your server is not reachable and sync later
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ struct SearchBookmarksView: View {
|
||||
@Binding var selectedBookmark: Bookmark?
|
||||
@Namespace private var namespace
|
||||
@State private var isFirstAppearance = true
|
||||
@State private var cardLayoutStyle: CardLayoutStyle = .magazine
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@ -61,10 +62,22 @@ struct SearchBookmarksView: View {
|
||||
}
|
||||
}
|
||||
}) {
|
||||
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }, namespace: namespace)
|
||||
BookmarkCardView(
|
||||
bookmark: bookmark,
|
||||
currentState: .all,
|
||||
layout: cardLayoutStyle,
|
||||
onArchive: {_ in },
|
||||
onDelete: {_ in },
|
||||
onToggleFavorite: {_ in }
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: cardLayoutStyle == .compact ? 8 : 12,
|
||||
leading: 16,
|
||||
bottom: cardLayoutStyle == .compact ? 8 : 12,
|
||||
trailing: 16
|
||||
))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
@ -91,14 +104,29 @@ struct SearchBookmarksView: View {
|
||||
set: { selectedBookmarkId = $0 }
|
||||
)
|
||||
) { bookmarkId in
|
||||
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
||||
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
||||
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||
}
|
||||
.onAppear {
|
||||
if isFirstAppearance {
|
||||
searchFieldIsFocused = true
|
||||
isFirstAppearance = false
|
||||
}
|
||||
loadCardLayoutStyle()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .cardLayoutChanged)) { notification in
|
||||
if let layout = notification.object as? CardLayoutStyle {
|
||||
cardLayoutStyle = layout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCardLayoutStyle() {
|
||||
Task {
|
||||
let loadCardLayoutUseCase = DefaultUseCaseFactory.shared.makeLoadCardLayoutUseCase()
|
||||
let layout = await loadCardLayoutUseCase.execute()
|
||||
await MainActor.run {
|
||||
cardLayoutStyle = layout
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
173
readeck/UI/Settings/AppearanceSettingsView.swift
Normal file
173
readeck/UI/Settings/AppearanceSettingsView.swift
Normal file
@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppearanceSettingsView: View {
|
||||
@State private var selectedCardLayout: CardLayoutStyle = .magazine
|
||||
@State private var selectedTheme: Theme = .system
|
||||
@State private var selectedTagSortOrder: TagSortOrder = .byCount
|
||||
@State private var fontViewModel: FontSettingsViewModel
|
||||
@State private var generalViewModel: SettingsGeneralViewModel
|
||||
|
||||
@EnvironmentObject private var appSettings: AppSettings
|
||||
|
||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
||||
private let settingsRepository: PSettingsRepository
|
||||
|
||||
init(
|
||||
factory: UseCaseFactory = DefaultUseCaseFactory.shared,
|
||||
fontViewModel: FontSettingsViewModel = FontSettingsViewModel(),
|
||||
generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()
|
||||
) {
|
||||
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
||||
self.settingsRepository = SettingsRepository()
|
||||
self.fontViewModel = fontViewModel
|
||||
self.generalViewModel = generalViewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Section {
|
||||
// Font Settings als NavigationLink
|
||||
NavigationLink {
|
||||
FontSelectionView(viewModel: fontViewModel)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Font")
|
||||
Spacer()
|
||||
Text("\(fontViewModel.selectedFontFamily.displayName) · \(fontViewModel.selectedFontSize.displayName)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Theme Picker (Menu statt Segmented)
|
||||
Picker("Theme", selection: $selectedTheme) {
|
||||
ForEach(Theme.allCases, id: \.self) { theme in
|
||||
Text(theme.displayName).tag(theme)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedTheme) {
|
||||
saveThemeSettings()
|
||||
}
|
||||
|
||||
// Card Layout als NavigationLink
|
||||
NavigationLink {
|
||||
CardLayoutSelectionView(
|
||||
selectedCardLayout: $selectedCardLayout,
|
||||
onSave: saveCardLayoutSettings
|
||||
)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Card Layout")
|
||||
Spacer()
|
||||
Text(selectedCardLayout.displayName)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await fontViewModel.loadFontSettings()
|
||||
await generalViewModel.loadGeneralSettings()
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSettings() {
|
||||
Task {
|
||||
// Load theme, card layout, and tag sort order from repository
|
||||
if let settings = try? await settingsRepository.loadSettings() {
|
||||
await MainActor.run {
|
||||
selectedTheme = settings.theme ?? .system
|
||||
selectedTagSortOrder = settings.tagSortOrder ?? .byCount
|
||||
}
|
||||
}
|
||||
selectedCardLayout = await loadCardLayoutUseCase.execute()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveThemeSettings() {
|
||||
Task {
|
||||
// Load current settings, update theme, and save back
|
||||
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
||||
settings.theme = selectedTheme
|
||||
try? await settingsRepository.saveSettings(settings)
|
||||
|
||||
// Notify app about theme change
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCardLayoutSettings() {
|
||||
Task {
|
||||
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
||||
// Notify other parts of the app about the change
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .cardLayoutChanged, object: selectedCardLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveTagSortOrderSettings() {
|
||||
Task {
|
||||
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
||||
settings.tagSortOrder = selectedTagSortOrder
|
||||
try? await settingsRepository.saveSettings(settings)
|
||||
|
||||
// Update AppSettings to trigger UI updates
|
||||
await MainActor.run {
|
||||
appSettings.settings?.tagSortOrder = selectedTagSortOrder
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
List {
|
||||
AppearanceSettingsView()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
137
readeck/UI/Settings/CacheSettingsView.swift
Normal file
137
readeck/UI/Settings/CacheSettingsView.swift
Normal file
@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct CacheSettingsView: View {
|
||||
@State private var cacheSize: String = "0 MB"
|
||||
@State private var maxCacheSize: Double = 200
|
||||
@State private var isClearing: Bool = false
|
||||
@State private var showClearAlert: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Current Cache Size")
|
||||
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Refresh") {
|
||||
updateCacheSize()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
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 {
|
||||
updateCacheSize()
|
||||
loadMaxCacheSize()
|
||||
}
|
||||
.alert("Clear Cache", isPresented: $showClearAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Clear", role: .destructive) {
|
||||
clearCache()
|
||||
}
|
||||
} message: {
|
||||
Text("This will remove all cached images. They will be downloaded again when needed.")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCacheSize() {
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let size):
|
||||
let mbSize = Double(size) / (1024 * 1024)
|
||||
self.cacheSize = String(format: "%.1f MB", mbSize)
|
||||
case .failure:
|
||||
self.cacheSize = "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMaxCacheSize() {
|
||||
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
||||
if let savedSize = savedSize {
|
||||
maxCacheSize = Double(savedSize) / (1024 * 1024)
|
||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize
|
||||
} else {
|
||||
maxCacheSize = 200
|
||||
let defaultBytes = UInt(200 * 1024 * 1024)
|
||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes
|
||||
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMaxCacheSize(_ newSize: Double) {
|
||||
let bytes = UInt(newSize * 1024 * 1024)
|
||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
||||
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
||||
}
|
||||
|
||||
private func clearCache() {
|
||||
isClearing = true
|
||||
|
||||
KingfisherManager.shared.cache.clearDiskCache {
|
||||
DispatchQueue.main.async {
|
||||
self.isClearing = false
|
||||
self.updateCacheSize()
|
||||
}
|
||||
}
|
||||
|
||||
KingfisherManager.shared.cache.clearMemoryCache()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
CacheSettingsView()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user