Compare commits
33 Commits
5b520995ac
...
a2c805b700
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
3
.gitignore
vendored
@ -66,3 +66,6 @@ fastlane/AuthKey_JZJCQWW9N3.p8
|
||||
|
||||
# Documentation
|
||||
documentation/
|
||||
|
||||
# macOS
|
||||
**/.DS_Store
|
||||
44
README.md
@ -7,24 +7,17 @@ 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.
|
||||
@ -34,6 +27,29 @@ Please report any bugs, crashes, or suggestions directly through TestFlight, or
|
||||
|
||||
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)
|
||||
|
||||
@ -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)")
|
||||
@ -46,11 +50,14 @@ class OfflineBookmarkManager {
|
||||
}
|
||||
|
||||
func getTags() -> [String] {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
|
||||
do {
|
||||
let tagEntities = try context.fetch(fetchRequest)
|
||||
return tagEntities.compactMap { $0.name }.sorted()
|
||||
return try context.safePerform { [weak self] in
|
||||
guard let self = self else { return [] }
|
||||
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
let tagEntities = try self.context.fetch(fetchRequest)
|
||||
return tagEntities.compactMap { $0.name }.sorted()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to fetch tags: \(error)")
|
||||
return []
|
||||
|
||||
@ -208,19 +208,15 @@ struct ShareBookmarkView: View {
|
||||
}
|
||||
|
||||
private func addCustomTag() {
|
||||
let trimmed = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText)
|
||||
let availableLabels = viewModel.labels.map { $0.name }
|
||||
let currentLabels = Array(viewModel.selectedLabels)
|
||||
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
|
||||
|
||||
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 = ""
|
||||
for label in uniqueLabels {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
}
|
||||
|
||||
viewModel.searchText = ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,8 +67,22 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
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
|
||||
@ -76,6 +90,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)")
|
||||
}
|
||||
@ -85,9 +105,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)")
|
||||
}
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -81,7 +81,9 @@
|
||||
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,
|
||||
@ -435,7 +437,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -468,7 +470,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -623,7 +625,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -667,7 +669,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
|
||||
"originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
|
||||
@ -241,7 +241,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 {
|
||||
|
||||
@ -50,6 +50,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
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -17,27 +17,28 @@ class LabelsRepository: PLabelsRepository {
|
||||
}
|
||||
|
||||
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 { [weak self] in
|
||||
guard let self = self else { return }
|
||||
for dto in dtos {
|
||||
if !self.tagExists(name: dto.name, in: backgroundContext) {
|
||||
dto.toEntity(context: backgroundContext)
|
||||
}
|
||||
}
|
||||
try backgroundContext.save()
|
||||
}
|
||||
try coreDataManager.context.save()
|
||||
}
|
||||
|
||||
private func tagExists(name: String) -> Bool {
|
||||
private func tagExists(name: String, in context: NSManagedObjectContext) -> 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
|
||||
}
|
||||
do {
|
||||
let count = try context.count(for: fetchRequest)
|
||||
return count > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return exists
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import Foundation
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
class OfflineSyncManager: ObservableObject {
|
||||
class OfflineSyncManager: ObservableObject, @unchecked Sendable {
|
||||
static let shared = OfflineSyncManager()
|
||||
|
||||
@Published var isSyncing = false
|
||||
@ -99,10 +99,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,8 +109,16 @@ class OfflineSyncManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func deleteOfflineBookmark(_ entity: ArticleURLEntity) {
|
||||
coreDataManager.context.delete(entity)
|
||||
coreDataManager.save()
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auto Sync on Server Connectivity Changes
|
||||
|
||||
@ -14,6 +14,8 @@ struct Settings {
|
||||
var theme: Theme? = nil
|
||||
var cardLayoutStyle: CardLayoutStyle? = nil
|
||||
|
||||
var urlOpener: UrlOpener? = nil
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
token != nil && !token!.isEmpty
|
||||
}
|
||||
@ -42,6 +44,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 {
|
||||
@ -82,6 +93,10 @@ 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
|
||||
}
|
||||
@ -123,7 +138,8 @@ class SettingsRepository: PSettingsRepository {
|
||||
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
||||
enableTTS: settingEntity?.enableTTS,
|
||||
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
|
||||
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue)
|
||||
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue),
|
||||
urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue)
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
} catch {
|
||||
@ -206,15 +222,6 @@ class SettingsRepository: PSettingsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
var hasFinishedSetup: Bool {
|
||||
get {
|
||||
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
|
||||
}
|
||||
set {
|
||||
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
||||
}
|
||||
}
|
||||
|
||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
|
||||
let context = coreDataManager.context
|
||||
|
||||
|
||||
@ -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
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
readeck/Domain/Model/UrlOpener.swift
Normal file
@ -0,0 +1,11 @@
|
||||
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,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,34 @@
|
||||
"%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";
|
||||
|
||||
@ -5,4 +5,156 @@
|
||||
Created by conversion from Localizable.xcstrings
|
||||
*/
|
||||
|
||||
"all" = "Ale";
|
||||
|
||||
"" = "";
|
||||
"(%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-Einstellungen";
|
||||
"Appearance" = "Darstellung";
|
||||
"Cache Settings" = "Cache-Einstellungen";
|
||||
"General Settings" = "Allgemeine Einstellungen";
|
||||
"Server Settings" = "Server-Einstellungen";
|
||||
"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";
|
||||
"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-Einstellungen";
|
||||
"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-Einstellungen";
|
||||
"Font size" = "Schriftgröße";
|
||||
"From Bremen with 💚" = "Aus Bremen mit 💚";
|
||||
"General" = "Allgemein";
|
||||
"Global Level" = "Globales Level";
|
||||
"Global Minimum Level" = "Globales Minimum-Level";
|
||||
"Global Settings" = "Globale Einstellungen";
|
||||
"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";
|
||||
"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";
|
||||
|
||||
|
||||
@ -18,6 +18,34 @@
|
||||
"%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";
|
||||
|
||||
@ -64,15 +64,14 @@ struct BookmarkDetailView: View {
|
||||
})
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 4)
|
||||
.animation(.easeInOut, value: webViewHeight)
|
||||
.padding(.horizontal, 4)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Loading article...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
@ -190,7 +189,7 @@ struct BookmarkDetailView: View {
|
||||
let offset = geo.frame(in: .global).minY
|
||||
ZStack(alignment: .top) {
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
.scaledToFit()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||
.clipped()
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
@ -263,7 +262,7 @@ struct BookmarkDetailView: View {
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
@ -319,7 +318,7 @@ struct BookmarkDetailView: View {
|
||||
|
||||
metaRow(icon: "safari") {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||
}) {
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||
.font(.subheadline)
|
||||
|
||||
@ -73,10 +73,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 = ""
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ extension View {
|
||||
|
||||
struct BookmarkCardView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
let bookmark: Bookmark
|
||||
let currentState: BookmarkState
|
||||
@ -255,7 +256,7 @@ struct BookmarkCardView: View {
|
||||
HStack {
|
||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||
.onTapGesture {
|
||||
SafariUtil.openInSafari(url: bookmark.url)
|
||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -275,8 +276,9 @@ struct BookmarkCardView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CachedAsyncImage(url: imageURL)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(minHeight: 180)
|
||||
.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 {
|
||||
@ -335,7 +337,7 @@ struct BookmarkCardView: View {
|
||||
HStack {
|
||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||
.onTapGesture {
|
||||
SafariUtil.openInSafari(url: bookmark.url)
|
||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -433,4 +435,4 @@ struct IconBadge: View {
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,6 +56,7 @@ struct BookmarksView: View {
|
||||
)
|
||||
) { bookmarkId in
|
||||
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
}
|
||||
.sheet(isPresented: $showingAddBookmark) {
|
||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||
@ -88,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
|
||||
@ -134,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)
|
||||
@ -151,7 +153,7 @@ struct BookmarksView: View {
|
||||
|
||||
Button("Try Again") {
|
||||
Task {
|
||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||
await viewModel.retryLoading()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
@ -13,6 +13,7 @@ class BookmarksViewModel {
|
||||
var isLoading = false
|
||||
var isInitialLoading = true
|
||||
var errorMessage: String?
|
||||
var isNetworkError = false
|
||||
var currentState: BookmarkState = .unread
|
||||
var currentType = [BookmarkType.article]
|
||||
var currentTag: String? = nil
|
||||
@ -123,8 +124,22 @@ 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
|
||||
}
|
||||
|
||||
@ -151,7 +166,20 @@ 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
|
||||
@ -162,6 +190,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 {
|
||||
@ -258,6 +293,10 @@ class BookmarksViewModel {
|
||||
private func executeDelete(bookmark: Bookmark) async {
|
||||
do {
|
||||
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
||||
// If delete succeeds, remove bookmark from the list
|
||||
await MainActor.run {
|
||||
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
|
||||
}
|
||||
} catch {
|
||||
// If delete fails, restore the bookmark
|
||||
await MainActor.run {
|
||||
|
||||
@ -133,6 +133,7 @@ struct TagManagementView: View {
|
||||
.textFieldStyle(CustomTextFieldStyle())
|
||||
.keyboardType(.default)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.onSubmit {
|
||||
onAddCustomTag()
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@ struct WebView: UIViewRepresentable {
|
||||
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")
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
@ -36,13 +35,10 @@ struct WebView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
|
||||
let isDarkMode = colorScheme == .dark
|
||||
|
||||
// Font Settings aus Settings-Objekt
|
||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
|
||||
|
||||
@ -228,24 +224,45 @@ struct WebView: UIViewRepresentable {
|
||||
<body>
|
||||
\(htmlContent)
|
||||
<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);
|
||||
img.addEventListener('load', debouncedHeightUpdate);
|
||||
});
|
||||
// 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);
|
||||
isScrolling = true;
|
||||
clearTimeout(scrollTimeout);
|
||||
|
||||
scrollTimeout = setTimeout(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);
|
||||
|
||||
setTimeout(function() {
|
||||
isScrolling = false;
|
||||
debouncedHeightUpdate();
|
||||
}, 200);
|
||||
}, 16);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@ -254,6 +271,15 @@ 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.loadHTMLString("", baseURL: nil)
|
||||
coordinator.cleanup()
|
||||
}
|
||||
|
||||
func makeCoordinator() -> WebViewCoordinator {
|
||||
WebViewCoordinator()
|
||||
}
|
||||
@ -282,9 +308,27 @@ struct WebView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||||
// Callbacks
|
||||
var onHeightChange: ((CGFloat) -> Void)?
|
||||
var onScroll: ((Double) -> Void)?
|
||||
var hasHeightUpdate: Bool = false
|
||||
|
||||
// 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 +344,88 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,6 +150,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 {
|
||||
|
||||
@ -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: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,59 +9,185 @@ 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
|
||||
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
||||
|
||||
@State private var selectedTab: SidebarTab = .unread
|
||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
||||
|
||||
|
||||
// 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 {
|
||||
NavigationStack {
|
||||
GlobalPlayerContainerView {
|
||||
TabView(selection: $selectedTabIndex) {
|
||||
mainTabsContent
|
||||
moreTabContent
|
||||
GlobalPlayerContainerView {
|
||||
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)
|
||||
}
|
||||
.accentColor(.accentColor)
|
||||
}
|
||||
.accentColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Tab Content
|
||||
|
||||
@ViewBuilder
|
||||
private var mainTabsContent: some View {
|
||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||
tabView(for: tab)
|
||||
.tabItem {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.tag(idx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var moreTabContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
if searchViewModel.searchQuery.isEmpty {
|
||||
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
|
||||
}
|
||||
} 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
|
||||
// Hidden NavigationLink to remove disclosure indicator
|
||||
// To restore: uncomment block below and remove ZStack
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
.navigationBarBackButtonHidden(false)
|
||||
} 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 {
|
||||
@ -70,12 +196,6 @@ struct PhoneTabView: View {
|
||||
tabView(for: tab)
|
||||
.navigationTitle(tab.label)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onDisappear {
|
||||
// tags and search handle navigation by own
|
||||
if tab != .tags && tab != .search {
|
||||
selectedMoreTab = nil
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
@ -121,17 +241,22 @@ struct PhoneTabView: View {
|
||||
case .archived:
|
||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
|
||||
case .search:
|
||||
SearchBookmarksView(selectedBookmark: .constant(nil))
|
||||
EmptyView() // search is directly implemented
|
||||
case .settings:
|
||||
SettingsView()
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
case .article:
|
||||
BookmarksView(state: .all, type: [.article], selectedBookmark: .constant(nil))
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
case .videos:
|
||||
BookmarksView(state: .all, type: [.video], selectedBookmark: .constant(nil))
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
case .pictures:
|
||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
case .tags:
|
||||
LabelsView(selectedTag: .constant(nil))
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,10 @@ class AppSettings: ObservableObject {
|
||||
var theme: Theme {
|
||||
settings?.theme ?? .system
|
||||
}
|
||||
|
||||
var urlOpener: UrlOpener {
|
||||
settings?.urlOpener ?? .inAppBrowser
|
||||
}
|
||||
|
||||
init(settings: Settings? = nil) {
|
||||
self.settings = settings
|
||||
|
||||
@ -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 })
|
||||
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))
|
||||
}
|
||||
@ -98,6 +111,22 @@ struct SearchBookmarksView: View {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,15 +6,17 @@ struct AppearanceSettingsView: View {
|
||||
|
||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
||||
private let settingsRepository: PSettingsRepository
|
||||
|
||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
||||
self.settingsRepository = SettingsRepository()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Appearance", icon: "paintbrush")
|
||||
SectionHeader(title: "Appearance".localized, icon: "paintbrush")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Theme Section
|
||||
@ -58,18 +60,29 @@ struct AppearanceSettingsView: View {
|
||||
}
|
||||
|
||||
private func loadSettings() {
|
||||
// Load theme setting
|
||||
let themeString = UserDefaults.standard.string(forKey: "selectedTheme") ?? "system"
|
||||
selectedTheme = Theme(rawValue: themeString) ?? .system
|
||||
|
||||
// Load card layout setting
|
||||
Task {
|
||||
// Load both theme and card layout from repository
|
||||
if let settings = try? await settingsRepository.loadSettings() {
|
||||
await MainActor.run {
|
||||
selectedTheme = settings.theme ?? .system
|
||||
}
|
||||
}
|
||||
selectedCardLayout = await loadCardLayoutUseCase.execute()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveThemeSettings() {
|
||||
UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme")
|
||||
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() {
|
||||
|
||||
@ -9,7 +9,7 @@ struct CacheSettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Cache Settings", icon: "internaldrive")
|
||||
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
|
||||
@ -16,7 +16,7 @@ struct FontSettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Font Settings", icon: "textformat")
|
||||
SectionHeader(title: "Font Settings".localized, icon: "textformat")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Font Family Picker
|
||||
|
||||
96
readeck/UI/Settings/LegalNoticeView.swift
Normal file
@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LegalNoticeView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Legal Notice")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
sectionView(
|
||||
title: "App Publisher",
|
||||
content: """
|
||||
Ilyas Hallak
|
||||
Albert-Bischof-Str. 18
|
||||
28357 Bremen
|
||||
Germany
|
||||
|
||||
Email: hi@ilyashallak.de
|
||||
"""
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "Content Responsibility",
|
||||
content: "The publisher is responsible for the content of this application in accordance with applicable laws."
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "App Information",
|
||||
content: """
|
||||
readeck iOS - Bookmark Management Client
|
||||
Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown")
|
||||
Build: \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown")
|
||||
|
||||
This app is an open source client for readeck bookmark management.
|
||||
"""
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "License",
|
||||
content: "This software is released under the MIT License. The source code is available at the official repository."
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "Disclaimer",
|
||||
content: "The app is provided \"as is\" without warranty of any kind. The publisher assumes no liability for damages arising from the use of this application."
|
||||
)
|
||||
|
||||
// TODO: Add business registration details if needed
|
||||
// sectionView(
|
||||
// title: "Business Registration",
|
||||
// content: """
|
||||
// [Business Registration Number]
|
||||
// [Tax ID / VAT Number]
|
||||
// [Responsible Authority]
|
||||
// """
|
||||
// )
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionView(title: String, content: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(content)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LegalNoticeView()
|
||||
}
|
||||
125
readeck/UI/Settings/LegalPrivacySettingsView.swift
Normal file
@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LegalPrivacySettingsView: View {
|
||||
@State private var showingPrivacyPolicy = false
|
||||
@State private var showingLegalNotice = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Privacy Policy
|
||||
Button(action: {
|
||||
showingPrivacyPolicy = true
|
||||
}) {
|
||||
HStack {
|
||||
Text(NSLocalizedString("Privacy Policy", comment: ""))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Legal Notice
|
||||
Button(action: {
|
||||
showingLegalNotice = true
|
||||
}) {
|
||||
HStack {
|
||||
Text(NSLocalizedString("Legal Notice", comment: ""))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Support Section
|
||||
VStack(spacing: 12) {
|
||||
// Report an Issue
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text(NSLocalizedString("Report an Issue", comment: ""))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Contact Support
|
||||
Button(action: {
|
||||
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text(NSLocalizedString("Contact Support", comment: ""))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingPrivacyPolicy) {
|
||||
PrivacyPolicyView()
|
||||
}
|
||||
.sheet(isPresented: $showingLegalNotice) {
|
||||
LegalNoticeView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LegalPrivacySettingsView()
|
||||
.cardStyle()
|
||||
.padding()
|
||||
}
|
||||
82
readeck/UI/Settings/PrivacyPolicyView.swift
Normal file
@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PrivacyPolicyView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Privacy Policy")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Text("Last updated: September 20, 2025")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
sectionView(
|
||||
title: "Data Collection",
|
||||
content: "readeck iOS does not collect, store, or transmit any personal data. The app operates as a client for your personal readeck server and all data remains on your device or your own server infrastructure."
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "Local Storage",
|
||||
content: "The app stores bookmarks locally on your device using CoreData for offline access. Login credentials are securely stored in the iOS Keychain. No data is shared with third parties."
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "Server Communication",
|
||||
content: "The app communicates only with your configured readeck server to synchronize bookmarks. No analytics, tracking, or telemetry data is collected or transmitted."
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "Third-Party Services",
|
||||
content: "This app does not use any third-party analytics, advertising, or tracking services. It does not integrate with social media platforms or other external services."
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "Your Rights",
|
||||
content: "Since no personal data is collected, processed, or stored by us, there is no personal data to access, modify, or delete from our side. All your data is under your control on your device and server."
|
||||
)
|
||||
|
||||
sectionView(
|
||||
title: "Contact",
|
||||
content: "If you have questions about this privacy policy, please contact us at: hi@ilyashallak.de"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionView(title: String, content: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(content)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PrivacyPolicyView()
|
||||
}
|
||||
@ -33,6 +33,9 @@ struct SettingsContainerView: View {
|
||||
SettingsServerView()
|
||||
.cardStyle()
|
||||
|
||||
LegalPrivacySettingsView()
|
||||
.cardStyle()
|
||||
|
||||
// Debug-only Logging Configuration
|
||||
if Bundle.main.isDebugBuild {
|
||||
debugSettingsSection
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsGeneralView: View {
|
||||
@State private var viewModel: SettingsGeneralViewModel
|
||||
@State private var viewModel: SettingsGeneralViewModel
|
||||
|
||||
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
|
||||
self.viewModel = viewModel
|
||||
@ -16,7 +16,7 @@ struct SettingsGeneralView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "General Settings", icon: "gear")
|
||||
SectionHeader(title: "General Settings".localized, icon: "gear")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@ -33,6 +33,23 @@ struct SettingsGeneralView: View {
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
// Reading Settings
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Open external links in".localized)
|
||||
.font(.headline)
|
||||
Picker("urlOpener", selection: $viewModel.urlOpener) {
|
||||
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
|
||||
Text(urlOpener.displayName.localized).tag(urlOpener)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.urlOpener) {
|
||||
Task {
|
||||
await viewModel.saveGeneralSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Sync Settings
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@ -55,8 +72,6 @@ struct SettingsGeneralView: View {
|
||||
.font(.headline)
|
||||
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
Toggle("Open external links in in-app Safari", isOn: $viewModel.openExternalLinksInApp)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
}
|
||||
|
||||
@ -15,8 +15,8 @@ class SettingsGeneralViewModel {
|
||||
// MARK: - Reading Settings
|
||||
var enableReaderMode: Bool = false
|
||||
var enableTTS: Bool = false
|
||||
var openExternalLinksInApp: Bool = true
|
||||
var autoMarkAsRead: Bool = false
|
||||
var urlOpener: UrlOpener = .inAppBrowser
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
@ -36,6 +36,7 @@ class SettingsGeneralViewModel {
|
||||
if let settings = try await loadSettingsUseCase.execute() {
|
||||
enableTTS = settings.enableTTS ?? false
|
||||
selectedTheme = settings.theme ?? .system
|
||||
urlOpener = settings.urlOpener ?? .inAppBrowser
|
||||
autoSyncEnabled = false
|
||||
}
|
||||
} catch {
|
||||
@ -48,6 +49,7 @@ class SettingsGeneralViewModel {
|
||||
do {
|
||||
try await saveSettingsUseCase.execute(enableTTS: enableTTS)
|
||||
try await saveSettingsUseCase.execute(theme: selectedTheme)
|
||||
try await saveSettingsUseCase.execute(urlOpener: urlOpener)
|
||||
|
||||
successMessage = "Settings saved"
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ struct SettingsServerView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: viewModel.isSetupMode ? "Server Settings" : "Server Connection", icon: "server.rack")
|
||||
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Text(viewModel.isSetupMode ?
|
||||
|
||||
@ -1,8 +1,25 @@
|
||||
import UIKit
|
||||
import SafariServices
|
||||
|
||||
class SafariUtil {
|
||||
static func openInSafari(url: String) {
|
||||
struct URLUtil {
|
||||
|
||||
static func open(url: String, urlOpener: UrlOpener = .inAppBrowser) {
|
||||
// Could be extended to open in other browsers like Firefox, Brave etc. if somebody has a multi browser setup
|
||||
// and wants readeck links to always opened in a specific browser
|
||||
switch urlOpener {
|
||||
case .defaultBrowser:
|
||||
openUrlInDefaultBrowser(url: url)
|
||||
default:
|
||||
openUrlInInAppBrowser(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
static func openUrlInDefaultBrowser(url: String) {
|
||||
guard let url = URL(string: url) else { return }
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
|
||||
static func openUrlInInAppBrowser(url: String) {
|
||||
guard let url = URL(string: url) else { return }
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
@ -22,9 +39,7 @@ class SafariUtil {
|
||||
presentingViewController.present(safariViewController, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct URLUtil {
|
||||
|
||||
static func extractDomain(from urlString: String) -> String? {
|
||||
guard let url = URL(string: urlString), let host = url.host else { return nil }
|
||||
return host.replacingOccurrences(of: "www.", with: "")
|
||||
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24G84" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="25A354" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="ArticleURLEntity" representedClassName="ArticleURLEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="tags" optional="YES" attributeType="String"/>
|
||||
@ -57,6 +57,7 @@
|
||||
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
||||
<attribute name="theme" optional="YES" attributeType="String"/>
|
||||
<attribute name="token" optional="YES" attributeType="String"/>
|
||||
<attribute name="urlOpener" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
|
||||
BIN
screenshots/appstore_ipad.pxd
Normal file
BIN
screenshots/appstore_iphone.pxd
Normal file
BIN
screenshots/ipad_1.jpg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
screenshots/ipad_2.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
screenshots/ipad_3.jpg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
screenshots/ipad_4.jpg
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
screenshots/ipad_5.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
screenshots/iphone_1.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
screenshots/iphone_2.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
screenshots/iphone_3.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
screenshots/iphone_4.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
screenshots/iphone_5.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |