Compare commits

...

33 Commits

Author SHA1 Message Date
a2c805b700 missing file 2025-10-04 00:42:41 +02:00
ad7ac19d79 refactor: Clean up PhoneTabView and improve code organization
- Remove unused state: selectedMoreTab, searchPath
- Remove obsolete navigation callbacks (.onAppear, .onDisappear)
- Hide disclosure indicators in search results using ZStack pattern
- Add computed properties for cardLayoutStyle and badge count
- Mark .search case as EmptyView (now directly implemented)
- Hide tab bar in more menu detail views
2025-10-04 00:36:39 +02:00
080c5aa4d2 feat: Modernize PhoneTabView with iOS 18/26 adaptive search
Implement version-specific search UI:
- iOS 26+: Dedicated search Tab with .searchable() and role .search
- iOS 18-25: Classic search bar integrated in More tab
- Each main tab now has independent NavigationStack with separate path
- Conditional view switches between menu and search results
- Remove .search from moreTabs array (now integrated)
- Direct binding to SearchBookmarksViewModel.searchQuery
2025-10-04 00:13:19 +02:00
f3d52b3c3a feat: Implement correct iOS 18 Tab API syntax
- Use Tab(title, systemImage:) without value parameter as per iOS 18 standards
- Remove manual selection handling as TabView handles it automatically
- Simplify ForEach to iterate directly over tabs instead of enumeration
- Remove .tag() and .tabItem modifiers which are no longer needed
- Clean up selection state management for modern Tab API
2025-10-01 21:58:19 +02:00
a651398dca fix: Revert to working tabItem syntax due to compiler error
- Revert Tab() syntax that caused compiler diagnostic error
- Use proven .tabItem approach that works reliably
- Keep modern Label() components for better accessibility
- Maintain all functionality while ensuring compilation success
2025-10-01 21:56:50 +02:00
58b89d4c86 refactor: Remove legacy tabItem code and use only modern Tab API
- Remove iOS version checks and legacy .tabItem implementations
- Use modern Tab() syntax throughout as app targets iOS 18+ minimum
- Simplify code by removing duplicate implementations
- Remove @available annotations as they're no longer needed
- Clean up code structure while maintaining all functionality
2025-10-01 21:56:11 +02:00
62f2f07f38 feat: Modernize PhoneTabView with iOS 18+ Tab API
- Add support for new SwiftUI Tab API (iOS 18+) alongside legacy tabItem
- Implement mainTabsContentNew and moreTabContentNew with modern Tab() syntax
- Maintain backward compatibility with iOS versions < 18
- Use @available annotations for version-specific implementations
- Replace deprecated .tabItem with cleaner Tab(..., value:) approach
- Keep all existing functionality including badges and navigation
2025-10-01 21:55:15 +02:00
99ef722e7d perf: Add simple caching to KeychainTokenProvider
- Cache token and endpoint in memory to avoid repeated keychain access
- First call reads from keychain, subsequent calls use cached values
- Significantly improves performance for frequent API calls
- Simple implementation without unnecessary locking or complexity

fix: Properly URL-encode labels parameter for API requests

- Add quotes around label values to match API requirements
- Fix label filtering for labels with spaces (e.g. 'aa aa')
- Ensure proper URL encoding as required by server
- Maintains existing pagination and filtering functionality
2025-10-01 21:51:34 +02:00
3ea4e49686 fix: Properly URL-encode labels parameter for API requests
- Add quotes around label values to match API requirements
- Fix label filtering for labels with spaces (e.g. 'aa aa')
- Ensure proper URL encoding as required by server
- Maintains existing pagination and filtering functionality
2025-10-01 21:36:59 +02:00
f42d138f58 refactor: Clean up WebView code and remove debug prints
- Remove all debug print statements for cleaner output
- Group related properties in WebViewCoordinator for better organization
- Remove redundant comments throughout the code
- Simplify JavaScript code by removing unnecessary comments
- Maintain all functionality while improving code readability
2025-09-30 23:21:46 +02:00
f50ad505ae fix: Add memory leak prevention and proper WebView cleanup
- Add dismantleUIView method to properly cleanup WebView resources
- Remove script message handlers to prevent memory leaks
- Add cleanup() method to WebViewCoordinator with timer invalidation
- Clear all callbacks and references when view is destroyed
- Add isCleanedUp guard to prevent double cleanup
- Improve memory management for better stability
2025-09-30 23:20:00 +02:00
4c180c6a81 updated readme 2025-09-27 22:49:35 +02:00
8739716348 updated readme 2025-09-27 22:47:56 +02:00
c8c93b76da update README with new iPhone and iPad screenshots 2025-09-27 22:44:12 +02:00
3abeb3f3e4 new screenshots for the readme 2025-09-27 22:04:11 +02:00
f3147a6cc6 Merge branch 'develop' of https://codeberg.org/readeck/readeck-ios into develop 2025-09-26 21:58:54 +02:00
ac7f4e66eb fix: Improve Core Data thread safety and resolve scrolling flicker
- Add background context support to CoreDataManager
- Fix TagEntity threading crashes in LabelsRepository
- Prevent WebView height updates during scrolling to reduce flicker
- Add App Store download link to README
2025-09-26 21:56:49 +02:00
Ilyas Hallak
413d3843aa Merge pull request 'General Settings: Select if readeck opens external links via in app or default browser' (#7) from christian-putzke/readeck-ios:feature/url_opener into develop
Reviewed-on: https://codeberg.org/readeck/readeck-ios/pulls/7
2025-09-26 21:55:47 +02:00
Christian Putzke
b929611430 Code review fixes 2025-09-26 20:45:38 +02:00
Christian Putzke
d369791f27 Merge branch 'develop' into feature/url_opener 2025-09-22 06:03:18 +02:00
2791b7f227 bumped build version 2025-09-20 22:21:16 +02:00
52bf16a8eb fix: Update Privacy Policy date from placeholder to current date 2025-09-20 22:18:15 +02:00
051b5b169d fix: Update contact details in legal views 2025-09-20 22:15:32 +02:00
d6ea56cfa9 feat: Add comprehensive i18n support and Legal & Privacy section
- Create String+Localization extension with .localized property
- Add LabelUtils for consistent label splitting and deduplication logic
- Implement Legal & Privacy settings section with Privacy Policy and Legal Notice views
- Add German/English localization for all navigation states and settings sections
- Fix navigationDestination placement warning in PadSidebarView
- Unify label input handling across main app and share extension
- Support for space-separated label input in share extension

Navigation & Settings now fully localized:
- All/Unread/Favorites/Archive → Alle/Ungelesen/Favoriten/Archiv
- Font/Appearance/Cache/General/Server Settings → German equivalents
- Legal section with GitHub issue reporting and email support contact
2025-09-20 22:14:17 +02:00
Christian Putzke
f78de1f740 Added setting to select in app or default browser to open external links 2025-09-18 22:35:43 +02:00
Christian Putzke
26990c59fa Ignore .DS_Store files 2025-09-18 22:16:42 +02:00
534ceddad4 bumped build version 2025-09-17 22:39:42 +02:00
dcbe0515fc fix: Share extension title extraction and theme persistence
- Enable text support in share extension to extract page titles
- Extract titles from attributedTitle and attributedContentText
- Prevent titles from being used as URLs with proper validation
- Fix theme settings persistence using SettingsRepository instead of UserDefaults
- Theme changes now properly notify the app for immediate updates
2025-09-17 22:27:52 +02:00
ba74430d10 feat: Improve label input functionality
- Split label input on space to create multiple labels at once
- Disable autocapitalization in tag search field
- Prevent duplicate labels when adding multiple at once
2025-09-17 13:36:36 +02:00
fbf840888a bumped build version 2025-09-05 21:59:18 +02:00
c13fc107b1 fix: Card width consistency and layout loading in search
- Fixed natural layout width using screen bounds instead of infinity
- Added card layout settings loading in SearchBookmarksView
- Consistent card width across all views prevents overflow
2025-09-05 21:58:24 +02:00
f40c5597f3 version bump 2025-09-04 21:36:49 +02:00
5947312339 fix: Core Data threading and network error handling
- Add thread-safe NSManagedObjectContext extension
- Fix EXC_BAD_ACCESS with performAndWait wrappers
- Add network error detection with retry functionality
- Change hero image to aspectFill for better layout
- Mark classes as @unchecked Sendable for Swift Concurrency
2025-09-04 21:15:54 +02:00
61 changed files with 1343 additions and 223 deletions

3
.gitignore vendored
View File

@ -66,3 +66,6 @@ fastlane/AuthKey_JZJCQWW9N3.p8
# Documentation
documentation/
# macOS
**/.DS_Store

View File

@ -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)

View File

@ -8,6 +8,8 @@
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>

View File

@ -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 []

View File

@ -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 = ""
}
}

View File

@ -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)")
}

View File

@ -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
```

View File

@ -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;

View File

@ -1,5 +1,5 @@
{
"originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
"originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457",
"pins" : [
{
"identity" : "kingfisher",

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}
}
}

View 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)
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View 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)
}
}
}

View 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"
}
}
}

View File

@ -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)
)
}
}

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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)

View File

@ -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 = ""
}

View File

@ -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())
}
}
}

View File

@ -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)

View File

@ -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 {

View File

@ -133,6 +133,7 @@ struct TagManagementView: View {
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.default)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onSubmit {
onAddCustomTag()
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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: "")
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
}

View File

@ -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: "")
}
}

View File

@ -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

View File

@ -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
}
}
}
}

View File

@ -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() {

View File

@ -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) {

View File

@ -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

View 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()
}

View 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()
}

View 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()
}

View File

@ -33,6 +33,9 @@ struct SettingsContainerView: View {
SettingsServerView()
.cardStyle()
LegalPrivacySettingsView()
.cardStyle()
// Debug-only Logging Configuration
if Bundle.main.isDebugBuild {
debugSettingsSection

View File

@ -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())
}

View File

@ -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"

View File

@ -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 ?

View File

@ -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: "")

View File

@ -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"/>

Binary file not shown.

Binary file not shown.

BIN
screenshots/ipad_1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
screenshots/ipad_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
screenshots/ipad_3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
screenshots/ipad_4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
screenshots/ipad_5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
screenshots/iphone_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
screenshots/iphone_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
screenshots/iphone_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
screenshots/iphone_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
screenshots/iphone_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB