Compare commits

...

10 Commits

Author SHA1 Message Date
af93c0e79a Add delete annotation feature with swipe gesture
Implemented ability to delete annotations via swipe-to-delete gesture in the annotations list view. Added close button with X icon to dismiss the annotations sheet.

Changes:
- Added DeleteAnnotationUseCase with repository integration
- Extended API with DELETE endpoint for annotations
- Implemented swipe-to-delete in AnnotationsListView
- Added error handling and optimistic UI updates
- Updated toolbar with close button (X icon)
2025-11-01 14:03:39 +01:00
a3825271f4 Improve debug logging system
- Redesign logging configuration UI with cleaner list-based navigation
- Replace segmented controls with detailed selection screens for better UX
- Add in-app debug log viewer with filtering and search capabilities
- Implement opt-in logging toggle to reduce device performance impact
- Add log storage system with 1000 entry limit
- Enable log export via share sheet
- Show warning banner when logging is disabled
2025-11-01 13:54:40 +01:00
6545e4c57b Redesign settings screen with native iOS style
- Refactor all settings views to use List with .insetGrouped style
- Create reusable SettingsRow components for consistent UI
- Separate onboarding flow into dedicated OnboardingServerView
- Consolidate font, theme, and card layout into unified Appearance section
- Add visual card layout previews in dedicated selection screen
- Move "Open links in" option to Appearance with descriptive footer
- Improve settings organization and native iOS feel
2025-10-31 23:39:59 +01:00
8bd39295c6 Bump build version to 33 2025-10-30 22:39:38 +01:00
5f44c8631b Simplify release notes for regular users 2025-10-30 22:27:14 +01:00
c2d7989be9 Update release notes for markdown rendering improvement 2025-10-30 21:54:02 +01:00
9e0ebbe0d7 Add MarkdownUI package and cleanup project structure
- Add swift-markdown-ui package dependency (v2.4.1)
- Remove old Logger.swift (moved to Utils/Logger.swift)
- Remove RELEASE_NOTES.md from Resources (moved to UI/Resources)
- Update German localization strings for settings sections
- Bump build version to 32
2025-10-30 21:50:33 +01:00
7c5f68e174 Refactor release notes to use MarkdownUI library
- Create MarkdownContentView to encapsulate MarkdownUI rendering
- Replace custom AttributedString markdown parsing with MarkdownUI
- Simplify ReleaseNotesView by removing manual markdown styling
- Improve markdown rendering with proper support for lists, links, and formatting
- Make markdown rendering easily replaceable by keeping it in a dedicated view
2025-10-30 21:48:28 +01:00
0ce3e45d7b Fix URL label translation and formatting
- Add localization keys for "open_url" and "open_original_page" in EN/DE
- Create URLUtil.openUrlLabel() helper function for consistent formatting
- Replace incorrect string concatenation with proper localized labels
- Fix: "example.comopen" now displays as "Open example.com" (EN) or "example.com öffnen" (DE)
- Update BookmarkDetailLegacyView, BookmarkDetailView2, and BookmarkCardView
2025-10-30 21:14:40 +01:00
7cac529c7e Fix annotation navigation by scrolling outer ScrollView instead of WebView
The JavaScript was executing scrollIntoView() but the WebView itself cannot
scroll (isScrollEnabled = false). Fixed by calculating the annotation's Y
position in the WebView and scrolling the outer ScrollView to the correct
position instead.

Changes:
- WebView.swift: Added onScrollToPosition callback and scrollToPosition
  message handler. JavaScript now calculates and sends annotation position
  to Swift instead of using scrollIntoView().
- NativeWebView.swift: Same changes for iOS 26+ with polling mechanism for
  window.__pendingScrollPosition.
- BookmarkDetailLegacyView.swift: Implemented onScrollToPosition callback
  that calculates final scroll position (header height + annotation position)
  and scrolls the outer ScrollView.
- BookmarkDetailView2.swift: Same implementation as BookmarkDetailLegacyView.
2025-10-30 21:07:13 +01:00
36 changed files with 2159 additions and 925 deletions

View File

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; };
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
/* End PBXBuildFile section */
@ -86,7 +87,6 @@
Data/Utils/LabelUtils.swift,
Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift,
Logger.swift,
readeck.xcdatamodeld,
Splash.storyboard,
UI/Components/Constants.swift,
@ -94,6 +94,8 @@
UI/Components/TagManagementView.swift,
UI/Components/UnifiedLabelChip.swift,
UI/Utils/NotificationNames.swift,
Utils/Logger.swift,
Utils/LogStore.swift,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};
@ -151,6 +153,7 @@
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -242,6 +245,7 @@
5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5D9D95482E623668009AF769 /* Kingfisher */,
5D48E6012EB402F50043F90F /* MarkdownUI */,
);
productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -333,6 +337,7 @@
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -437,7 +442,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -470,7 +475,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -625,7 +630,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -669,7 +674,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -854,6 +859,14 @@
minimumVersion = 1.21.0;
};
};
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.4.1;
};
};
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
@ -878,6 +891,11 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = netfox;
};
5D48E6012EB402F50043F90F /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
5D9D95482E623668009AF769 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;

View File

@ -1,5 +1,5 @@
{
"originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457",
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635",
"pins" : [
{
"identity" : "kingfisher",
@ -19,6 +19,15 @@
"version" : "1.21.0"
}
},
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "r.swift",
"kind" : "remoteSourceControl",
@ -37,6 +46,24 @@
"version" : "1.6.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
},
{
"identity" : "xcodeedit",
"kind" : "remoteSourceControl",

View File

@ -20,6 +20,7 @@ protocol PAPI {
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
}
class API: PAPI {
@ -486,6 +487,44 @@ class API: PAPI {
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
return result
}
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
logger.info("Deleting annotation: \(annotationId) from bookmark: \(bookmarkId)")
let baseURL = await self.baseURL
let fullEndpoint = "/api/bookmarks/\(bookmarkId)/annotations/\(annotationId)"
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = await tokenProvider.getToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for DELETE \(url.absoluteString)")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
handleUnauthorizedResponse(httpResponse.statusCode)
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
logger.info("Successfully deleted annotation: \(annotationId)")
}
}
enum HTTPMethod: String {

View File

@ -21,4 +21,8 @@ class AnnotationsRepository: PAnnotationsRepository {
)
}
}
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -1,3 +1,4 @@
protocol PAnnotationsRepository {
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
}

View File

@ -0,0 +1,17 @@
import Foundation
protocol PDeleteAnnotationUseCase {
func execute(bookmarkId: String, annotationId: String) async throws
}
class DeleteAnnotationUseCase: PDeleteAnnotationUseCase {
private let repository: PAnnotationsRepository
init(repository: PAnnotationsRepository) {
self.repository = repository
}
func execute(bookmarkId: String, annotationId: String) async throws {
try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
}
}

View File

@ -40,11 +40,11 @@
"Tags" = "Labels";
/* Settings Sections */
"Font Settings" = "Schriftart-Einstellungen";
"Font Settings" = "Schriftart";
"Appearance" = "Darstellung";
"Cache Settings" = "Cache-Einstellungen";
"General Settings" = "Allgemeine Einstellungen";
"Server Settings" = "Server-Einstellungen";
"Cache Settings" = "Cache";
"General Settings" = "Allgemein";
"Server Settings" = "Server";
"Server Connection" = "Server-Verbindung";
"Open external links in" = "Öffne externe Links in";
"In App Browser" = "In App Browser";
@ -67,7 +67,7 @@
"Critical" = "Kritisch";
"Debug" = "Debug";
"DEBUG BUILD" = "DEBUG BUILD";
"Debug Settings" = "Debug-Einstellungen";
"Debug Settings" = "Debug";
"Delete" = "Löschen";
"Delete Bookmark" = "Lesezeichen löschen";
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
@ -80,13 +80,13 @@
"Finished reading?" = "Fertig gelesen?";
"Font" = "Schrift";
"Font family" = "Schriftart";
"Font Settings" = "Schrift-Einstellungen";
"Font Settings" = "Schrift";
"Font size" = "Schriftgröße";
"From Bremen with 💚" = "Aus Bremen mit 💚";
"General" = "Allgemein";
"Global Level" = "Globales Level";
"Global Minimum Level" = "Globales Minimum-Level";
"Global Settings" = "Globale Einstellungen";
"Global Settings" = "Global";
"https://example.com" = "https://example.com";
"https://readeck.example.com" = "https://readeck.example.com";
"Include Source Location" = "Quellort einschließen";
@ -105,6 +105,8 @@
"More" = "Mehr";
"New Bookmark" = "Neues Lesezeichen";
"No articles in the queue" = "Keine Artikel in der Warteschlange";
"open_url" = "%@ öffnen";
"open_original_page" = "Originalseite öffnen";
"No bookmarks" = "Keine Lesezeichen";
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
"No bookmarks found." = "Keine Lesezeichen gefunden.";

View File

@ -101,6 +101,8 @@
"More" = "More";
"New Bookmark" = "New Bookmark";
"No articles in the queue" = "No articles in the queue";
"open_url" = "Open %@";
"open_original_page" = "Open original page";
"No bookmarks" = "No bookmarks";
"No bookmarks found in %@." = "No bookmarks found in %@.";
"No bookmarks found." = "No bookmarks found.";

View File

@ -64,6 +64,15 @@ struct AnnotationsListView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task {
await viewModel.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotation.id)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
case .error:
@ -74,8 +83,10 @@ struct AnnotationsListView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
}
}
}

View File

@ -3,6 +3,7 @@ import Foundation
@Observable
class AnnotationsListViewModel {
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
private let deleteAnnotationUseCase: PDeleteAnnotationUseCase
var annotations: [Annotation] = []
var isLoading = false
@ -11,6 +12,7 @@ class AnnotationsListViewModel {
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
self.deleteAnnotationUseCase = factory.makeDeleteAnnotationUseCase()
}
@MainActor
@ -26,4 +28,15 @@ class AnnotationsListViewModel {
showErrorAlert = true
}
}
@MainActor
func deleteAnnotation(bookmarkId: String, annotationId: String) async {
do {
try await deleteAnnotationUseCase.execute(bookmarkId: bookmarkId, annotationId: annotationId)
annotations.removeAll { $0.id == annotationId }
} catch {
errorMessage = "Failed to delete annotation"
showErrorAlert = true
}
}
}

View File

@ -101,6 +101,16 @@ struct BookmarkDetailLegacyView: View {
endSelector: endSelector
)
}
},
onScrollToPosition: { position in
// Calculate scroll position: add header height and webview offset
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
let targetPosition = imageHeight + position
// Scroll to the annotation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollPosition = ScrollPosition(y: targetPosition)
}
}
)
.frame(height: webViewHeight)
@ -116,7 +126,7 @@ struct BookmarkDetailLegacyView: View {
}) {
HStack {
Image(systemName: "safari")
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
@ -392,7 +402,7 @@ struct BookmarkDetailLegacyView: View {
}) {
HStack {
Image(systemName: "safari")
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
@ -446,7 +456,7 @@ struct BookmarkDetailLegacyView: View {
Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) {
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
.font(.subheadline)
.foregroundColor(.secondary)
}

View File

@ -411,7 +411,7 @@ struct BookmarkDetailView2: View {
Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) {
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
.font(.subheadline)
.foregroundColor(.secondary)
}
@ -475,6 +475,16 @@ struct BookmarkDetailView2: View {
endSelector: endSelector
)
}
},
onScrollToPosition: { position in
// Calculate scroll position: add header height and webview offset
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
let targetPosition = imageHeight + position
// Scroll to the annotation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollPosition = ScrollPosition(y: targetPosition)
}
}
)
.frame(height: webViewHeight)
@ -491,7 +501,7 @@ struct BookmarkDetailView2: View {
}) {
HStack {
Image(systemName: "safari")
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
}
.font(.title3.bold())
.frame(maxWidth: .infinity)

View File

@ -254,7 +254,7 @@ struct BookmarkCardView: View {
}
}
HStack {
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
.onTapGesture {
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
}
@ -335,7 +335,7 @@ struct BookmarkCardView: View {
}
}
HStack {
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
.onTapGesture {
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
}

View File

@ -13,6 +13,7 @@ struct NativeWebView: View {
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
var onScrollToPosition: ((CGFloat) -> Void)? = nil
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
@ -23,6 +24,7 @@ struct NativeWebView: View {
.onAppear {
loadStyledContent()
setupAnnotationMessageHandler()
setupScrollToPositionHandler()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
@ -82,6 +84,38 @@ struct NativeWebView: View {
}
}
}
private func setupScrollToPositionHandler() {
guard let onScrollToPosition = onScrollToPosition else { return }
// Poll for scroll position messages from JavaScript
Task { @MainActor in
let page = webPage
while true {
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
let script = """
return (function() {
if (window.__pendingScrollPosition !== undefined) {
const position = window.__pendingScrollPosition;
window.__pendingScrollPosition = undefined;
return position;
}
return null;
})();
"""
do {
if let position = try await page.callJavaScript(script) as? Double {
onScrollToPosition(CGFloat(position))
}
} catch {
// Silently continue polling
}
}
}
}
private func updateContentHeightWithJS() async {
var lastHeight: CGFloat = 0
@ -627,8 +661,15 @@ struct NativeWebView: View {
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) {
selectedElement.classList.add('selected');
// Get the element's position relative to the document
const rect = selectedElement.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elementTop = rect.top + scrollTop;
// Send position to Swift via polling mechanism
setTimeout(() => {
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.__pendingScrollPosition = elementTop;
}, 100);
}
}

View File

@ -0,0 +1,308 @@
//
// SettingsRow.swift
// readeck
//
// Created by Ilyas Hallak on 31.10.25.
//
import SwiftUI
// MARK: - Settings Row with Navigation Link
struct SettingsRowNavigationLink<Destination: View>: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let destination: Destination
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
@ViewBuilder destination: () -> Destination
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.destination = destination()
}
var body: some View {
NavigationLink(destination: destination) {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: subtitle
)
}
}
}
// MARK: - Settings Row with Toggle
struct SettingsRowToggle: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
@Binding var isOn: Bool
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
isOn: Binding<Bool>
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self._isOn = isOn
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: subtitle
)
Toggle("", isOn: $isOn)
.labelsHidden()
}
}
}
// MARK: - Settings Row with Value Display
struct SettingsRowValue: View {
let icon: String?
let iconColor: Color
let title: String
let value: String
let valueColor: Color
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
value: String,
valueColor: Color = .secondary
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.value = value
self.valueColor = valueColor
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: nil
)
Spacer()
Text(value)
.foregroundColor(valueColor)
}
}
}
// MARK: - Settings Row Button (for actions)
struct SettingsRowButton: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let destructive: Bool
let action: () -> Void
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
subtitle: String? = nil,
destructive: Bool = false,
action: @escaping () -> Void
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.destructive = destructive
self.action = action
}
var body: some View {
Button(action: action) {
SettingsRowLabel(
icon: icon,
iconColor: destructive ? .red : iconColor,
title: title,
subtitle: subtitle,
titleColor: destructive ? .red : .primary
)
}
}
}
// MARK: - Settings Row with Picker
struct SettingsRowPicker<T: Hashable>: View {
let icon: String?
let iconColor: Color
let title: String
let selection: Binding<T>
let options: [(value: T, label: String)]
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
selection: Binding<T>,
options: [(value: T, label: String)]
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.selection = selection
self.options = options
}
var body: some View {
HStack {
SettingsRowLabel(
icon: icon,
iconColor: iconColor,
title: title,
subtitle: nil
)
Spacer()
Picker("", selection: selection) {
ForEach(options, id: \.value) { option in
Text(option.label).tag(option.value)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
}
}
// MARK: - Settings Row Label (internal component)
struct SettingsRowLabel: View {
let icon: String?
let iconColor: Color
let title: String
let subtitle: String?
let titleColor: Color
init(
icon: String?,
iconColor: Color,
title: String,
subtitle: String?,
titleColor: Color = .primary
) {
self.icon = icon
self.iconColor = iconColor
self.title = title
self.subtitle = subtitle
self.titleColor = titleColor
}
var body: some View {
HStack(spacing: 12) {
if let icon = icon {
Image(systemName: icon)
.foregroundColor(iconColor)
.frame(width: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.foregroundColor(titleColor)
if let subtitle = subtitle {
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Previews
#Preview("Navigation Link") {
List {
SettingsRowNavigationLink(
icon: "paintbrush",
title: "App Icon",
subtitle: nil
) {
Text("Detail View")
}
}
.listStyle(.insetGrouped)
}
#Preview("Toggle") {
List {
SettingsRowToggle(
icon: "speaker.wave.2",
title: "Read Aloud Feature",
subtitle: "Text-to-Speech functionality",
isOn: .constant(true)
)
}
.listStyle(.insetGrouped)
}
#Preview("Value Display") {
List {
SettingsRowValue(
icon: "paintbrush.fill",
iconColor: .purple,
title: "Tint Color",
value: "Purple"
)
}
.listStyle(.insetGrouped)
}
#Preview("Button") {
List {
SettingsRowButton(
icon: "trash",
iconColor: .red,
title: "Clear Cache",
subtitle: "Remove all cached images",
destructive: true
) {
print("Clear cache tapped")
}
}
.listStyle(.insetGrouped)
}
#Preview("Picker") {
List {
SettingsRowPicker(
icon: "textformat",
title: "Font Family",
selection: .constant("System"),
options: [
("System", "System"),
("Serif", "Serif"),
("Monospace", "Monospace")
]
)
}
.listStyle(.insetGrouped)
}

View File

@ -8,6 +8,7 @@ struct WebView: UIViewRepresentable {
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
var onScrollToPosition: ((CGFloat) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView {
@ -31,9 +32,11 @@ struct WebView: UIViewRepresentable {
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
webView.configuration.userContentController.add(context.coordinator, name: "scrollToPosition")
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.onScrollToPosition = onScrollToPosition
context.coordinator.webView = webView
return webView
@ -43,6 +46,7 @@ struct WebView: UIViewRepresentable {
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.onScrollToPosition = onScrollToPosition
let isDarkMode = colorScheme == .dark
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
@ -332,6 +336,7 @@ struct WebView: UIViewRepresentable {
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition")
webView.loadHTMLString("", baseURL: nil)
coordinator.cleanup()
}
@ -379,8 +384,15 @@ struct WebView: UIViewRepresentable {
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) {
selectedElement.classList.add('selected');
// Get the element's position relative to the document
const rect = selectedElement.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elementTop = rect.top + scrollTop;
// Send position to Swift
setTimeout(() => {
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop);
}, 100);
}
}
@ -647,6 +659,7 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
var onScrollToPosition: ((CGFloat) -> Void)?
// WebView reference
weak var webView: WKWebView?
@ -702,6 +715,11 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
}
}
if message.name == "scrollToPosition", let position = message.body as? Double {
DispatchQueue.main.async {
self.onScrollToPosition?(CGFloat(position))
}
}
}
private func handleHeightUpdate(height: CGFloat) {
@ -778,5 +796,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
onHeightChange = nil
onScroll = nil
onAnnotationCreated = nil
onScrollToPosition = nil
}
}

View File

@ -22,6 +22,7 @@ protocol UseCaseFactory {
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
}
@ -125,4 +126,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
}
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
return DeleteAnnotationUseCase(repository: annotationsRepository)
}
}

View File

@ -92,6 +92,10 @@ class MockUseCaseFactory: UseCaseFactory {
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
MockGetBookmarkAnnotationsUseCase()
}
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
MockDeleteAnnotationUseCase()
}
}
@ -250,6 +254,12 @@ class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
}
}
class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase {
func execute(bookmarkId: String, annotationId: String) async throws {
// Mock implementation - do nothing
}
}
extension Bookmark {
static let mock: Bookmark = .init(
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)

View File

@ -0,0 +1,175 @@
//
// OnboardingServerView.swift
// readeck
//
// Created by Ilyas Hallak on 31.10.25.
//
import SwiftUI
struct OnboardingServerView: View {
@State private var viewModel = SettingsServerViewModel()
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Server Settings".localized, icon: "server.rack")
.padding(.bottom, 4)
Text("Enter your Readeck server details to get started.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
// Form
VStack(spacing: 16) {
// Server Endpoint
VStack(alignment: .leading, spacing: 8) {
TextField("",
text: $viewModel.endpoint,
prompt: Text("Server Endpoint").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.endpoint) {
viewModel.clearMessages()
}
// Quick Input Chips
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
QuickInputChip(text: "http://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "http://" + viewModel.endpoint
}
})
QuickInputChip(text: "https://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "https://" + viewModel.endpoint
}
})
QuickInputChip(text: "192.168.", action: {
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
if viewModel.endpoint.starts(with: "http") {
viewModel.endpoint += "192.168."
} else {
viewModel.endpoint = "http://192.168."
}
}
})
QuickInputChip(text: ":8000", action: {
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
viewModel.endpoint += ":8000"
}
})
}
.padding(.horizontal, 1)
}
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Username
VStack(alignment: .leading, spacing: 8) {
TextField("",
text: $viewModel.username,
prompt: Text("Username").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.username) {
viewModel.clearMessages()
}
}
// Password
VStack(alignment: .leading, spacing: 8) {
SecureField("",
text: $viewModel.password,
prompt: Text("Password").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.password) {
viewModel.clearMessages()
}
}
}
// Messages
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
}
}
VStack(spacing: 10) {
Button(action: {
Task {
await viewModel.saveServerSettings()
}
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canLogin || viewModel.isLoading)
}
}
.task {
await viewModel.loadServerSettings()
}
}
}
// MARK: - Quick Input Chip Component
struct QuickInputChip: View {
let text: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(text)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(12)
}
}
}
#Preview {
OnboardingServerView()
.padding()
}

View File

@ -18,22 +18,17 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi
### Performance Improvements
- **Dramatically faster label loading** - especially with 1000+ labels
- Labels now load instantly from local cache, then sync in background
- Optimized label management to prevent crashes and lag
- Share Extension now loads labels without delay
- Reduced memory usage when working with large label collections
- Better offline support - labels always available even without internet
- Labels now load instantly, even without internet connection
- Share Extension loads much faster
- Better performance when working with many labels
- Improved overall app stability
### Fixes & Improvements
- Centralized color management for consistent appearance
- Improved annotation creation workflow
- Better text selection handling in article view
- Implemented lazy loading for label lists
- Switched to Core Data as primary source for labels
- Batch operations for faster database queries
- Background sync to keep labels up-to-date without blocking the UI
- Fixed duplicate ID warnings in label lists
- Better color consistency throughout the app
- Improved text selection in articles
- Better formatted release notes
- Various bug fixes and stability improvements
---

View File

@ -3,62 +3,120 @@ import SwiftUI
struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system
@State private var fontViewModel: FontSettingsViewModel
@State private var generalViewModel: SettingsGeneralViewModel
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
private let settingsRepository: PSettingsRepository
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
init(
factory: UseCaseFactory = DefaultUseCaseFactory.shared,
fontViewModel: FontSettingsViewModel = FontSettingsViewModel(),
generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()
) {
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
self.settingsRepository = SettingsRepository()
self.fontViewModel = fontViewModel
self.generalViewModel = generalViewModel
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Appearance".localized, icon: "paintbrush")
.padding(.bottom, 4)
// Theme Section
VStack(alignment: .leading, spacing: 12) {
Text("Theme")
.font(.headline)
Group {
Section {
// Font Family
Picker("Font family", selection: $fontViewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family)
}
}
.onChange(of: fontViewModel.selectedFontFamily) {
Task {
await fontViewModel.saveFontSettings()
}
}
// Font Size
Picker("Font size", selection: $fontViewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
.pickerStyle(.segmented)
.onChange(of: fontViewModel.selectedFontSize) {
Task {
await fontViewModel.saveFontSettings()
}
}
// Font Preview - direkt in der gleichen Section
VStack(alignment: .leading, spacing: 6) {
Text("readeck Bookmark Title")
.font(fontViewModel.previewTitleFont)
.fontWeight(.semibold)
.lineLimit(1)
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
.font(fontViewModel.previewBodyFont)
.lineLimit(3)
Text("12 min • Today • example.com")
.font(fontViewModel.previewCaptionFont)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
.listRowBackground(Color(.systemGray6))
// Theme Picker (Menu statt Segmented)
Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme)
}
}
.pickerStyle(.segmented)
.onChange(of: selectedTheme) {
saveThemeSettings()
}
}
Divider()
// Card Layout Section
VStack(alignment: .leading, spacing: 12) {
Text("Card Layout")
.font(.headline)
VStack(spacing: 16) {
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
CardLayoutPreview(
layout: layout,
isSelected: selectedCardLayout == layout
) {
selectedCardLayout = layout
saveCardLayoutSettings()
}
// Card Layout als NavigationLink
NavigationLink {
CardLayoutSelectionView(
selectedCardLayout: $selectedCardLayout,
onSave: saveCardLayoutSettings
)
} label: {
HStack {
Text("Card Layout")
Spacer()
Text(selectedCardLayout.displayName)
.foregroundColor(.secondary)
}
}
// Open external links in
Picker("Open links in", selection: $generalViewModel.urlOpener) {
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
Text(urlOpener.displayName).tag(urlOpener)
}
}
.onChange(of: generalViewModel.urlOpener) {
Task {
await generalViewModel.saveGeneralSettings()
}
}
} header: {
Text("Appearance")
} footer: {
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.")
}
}
.onAppear {
.task {
await fontViewModel.loadFontSettings()
await generalViewModel.loadGeneralSettings()
loadSettings()
}
}
private func loadSettings() {
Task {
// Load both theme and card layout from repository
@ -70,21 +128,21 @@ struct AppearanceSettingsView: View {
selectedCardLayout = await loadCardLayoutUseCase.execute()
}
}
private func saveThemeSettings() {
Task {
// Load current settings, update theme, and save back
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
settings.theme = selectedTheme
try? await settingsRepository.saveSettings(settings)
// Notify app about theme change
await MainActor.run {
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
}
}
private func saveCardLayoutSettings() {
Task {
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
@ -96,139 +154,11 @@ struct AppearanceSettingsView: View {
}
}
struct CardLayoutPreview: View {
let layout: CardLayoutStyle
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 12) {
// Visual Preview
switch layout {
case .compact:
// Compact: Small image on left, content on right
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue.opacity(0.6))
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 6)
.frame(maxWidth: .infinity)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 60)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.4))
.frame(height: 4)
.frame(maxWidth: 40)
}
}
.padding(8)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 50)
case .magazine:
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 40)
Text("Fixed 140px")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 65)
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
case .natural:
VStack(spacing: 3) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 38)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 35)
Text("Original ratio")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 75) // Höher als Magazine
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
}
VStack(alignment: .leading, spacing: 4) {
Text(layout.displayName)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(layout.description)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
#Preview {
NavigationStack {
List {
AppearanceSettingsView()
}
.buttonStyle(.plain)
.listStyle(.insetGrouped)
}
}
#Preview {
AppearanceSettingsView()
.cardStyle()
.padding()
}

View File

@ -6,79 +6,67 @@ struct CacheSettingsView: View {
@State private var maxCacheSize: Double = 200
@State private var isClearing: Bool = false
@State private var showClearAlert: Bool = false
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
.padding(.bottom, 4)
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size")
.foregroundColor(.primary)
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Refresh") {
updateCacheSize()
}
.font(.caption)
.foregroundColor(.blue)
Section {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size")
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Max Cache Size")
.foregroundColor(.primary)
Spacer()
Text("\(Int(maxCacheSize)) MB")
.font(.caption)
.foregroundColor(.secondary)
}
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
Text("Max Cache Size")
}
.onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue)
}
.accentColor(.blue)
Spacer()
Button("Refresh") {
updateCacheSize()
}
Divider()
Button(action: {
showClearAlert = true
}) {
HStack {
if isClearing {
ProgressView()
.scaleEffect(0.8)
.frame(width: 24)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
.frame(width: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text("Clear Cache")
.foregroundColor(isClearing ? .secondary : .red)
Text("Remove all cached images")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
.disabled(isClearing)
.font(.caption)
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Max Cache Size")
Spacer()
Text("\(Int(maxCacheSize)) MB")
.font(.caption)
.foregroundColor(.secondary)
}
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
Text("Max Cache Size")
}
.onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue)
}
}
Button(action: {
showClearAlert = true
}) {
HStack {
if isClearing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
}
VStack(alignment: .leading, spacing: 2) {
Text("Clear Cache")
.foregroundColor(isClearing ? .secondary : .red)
Text("Remove all cached images")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
.disabled(isClearing)
} header: {
Text("Cache Settings")
}
.onAppear {
updateCacheSize()
@ -93,7 +81,7 @@ struct CacheSettingsView: View {
Text("This will remove all cached images. They will be downloaded again when needed.")
}
}
private func updateCacheSize() {
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
DispatchQueue.main.async {
@ -107,7 +95,7 @@ struct CacheSettingsView: View {
}
}
}
private func loadMaxCacheSize() {
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
if let savedSize = savedSize {
@ -120,29 +108,30 @@ struct CacheSettingsView: View {
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
}
}
private func updateMaxCacheSize(_ newSize: Double) {
let bytes = UInt(newSize * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
}
private func clearCache() {
isClearing = true
KingfisherManager.shared.cache.clearDiskCache {
DispatchQueue.main.async {
self.isClearing = false
self.updateCacheSize()
}
}
KingfisherManager.shared.cache.clearMemoryCache()
}
}
#Preview {
CacheSettingsView()
.cardStyle()
.padding()
}
List {
CacheSettingsView()
}
.listStyle(.insetGrouped)
}

View File

@ -0,0 +1,171 @@
//
// CardLayoutSelectionView.swift
// readeck
//
// Created by Ilyas Hallak on 31.10.25.
//
import SwiftUI
struct CardLayoutSelectionView: View {
@Binding var selectedCardLayout: CardLayoutStyle
@Environment(\.dismiss) private var dismiss
let onSave: () -> Void
var body: some View {
List {
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
CardLayoutPreview(
layout: layout,
isSelected: selectedCardLayout == layout
) {
selectedCardLayout = layout
onSave()
dismiss()
}
}
}
.listStyle(.plain)
.navigationTitle("Card Layout")
.navigationBarTitleDisplayMode(.inline)
}
}
struct CardLayoutPreview: View {
let layout: CardLayoutStyle
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 12) {
// Visual Preview
switch layout {
case .compact:
// Compact: Small image on left, content on right
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue.opacity(0.6))
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 6)
.frame(maxWidth: .infinity)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 60)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.4))
.frame(height: 4)
.frame(maxWidth: 40)
}
}
.padding(8)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 50)
case .magazine:
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 40)
Text("Fixed 140px")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 65)
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
case .natural:
VStack(spacing: 3) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 38)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 35)
Text("Original ratio")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 75)
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
}
VStack(alignment: .leading, spacing: 4) {
Text(layout.displayName)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(layout.description)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
#Preview {
NavigationStack {
CardLayoutSelectionView(
selectedCardLayout: .constant(.magazine),
onSave: {}
)
}
}

View File

@ -0,0 +1,444 @@
//
// DebugLogViewer.swift
// readeck
//
// Created by Ilyas Hallak on 01.11.25.
//
import SwiftUI
struct DebugLogViewer: View {
@State private var entries: [LogEntry] = []
@State private var selectedLevel: LogLevel?
@State private var selectedCategory: LogCategory?
@State private var searchText = ""
@State private var showShareSheet = false
@State private var exportText = ""
@State private var autoScroll = true
@State private var showFilters = false
@StateObject private var logConfig = LogConfiguration.shared
private let logger = Logger.ui
var body: some View {
VStack(spacing: 0) {
// Logging Disabled Warning
if !logConfig.isLoggingEnabled {
loggingDisabledBanner
}
// Filter Bar
if showFilters {
filterBar
}
// Log List
if filteredEntries.isEmpty {
emptyState
} else {
ScrollViewReader { proxy in
List {
ForEach(filteredEntries) { entry in
LogEntryRow(entry: entry)
}
}
.listStyle(.plain)
.onChange(of: entries.count) { oldValue, newValue in
if autoScroll, let lastEntry = filteredEntries.last {
withAnimation {
proxy.scrollTo(lastEntry.id, anchor: .bottom)
}
}
}
}
}
}
.navigationTitle("Debug Logs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Menu {
Button {
showFilters.toggle()
} label: {
Label(
showFilters ? "Hide Filters" : "Show Filters",
systemImage: "line.3.horizontal.decrease.circle"
)
}
Button {
autoScroll.toggle()
} label: {
Label(
autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll",
systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle"
)
}
Divider()
Button {
Task {
await refreshLogs()
}
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
Button {
Task {
await exportLogs()
}
} label: {
Label("Export Logs", systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) {
Task {
await clearLogs()
}
} label: {
Label("Clear All Logs", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.searchable(text: $searchText, prompt: "Search logs")
.task {
await refreshLogs()
}
.sheet(isPresented: $showShareSheet) {
ActivityView(activityItems: [exportText])
}
}
@ViewBuilder
private var filterBar: some View {
VStack(spacing: 8) {
HStack {
Text("Filters")
.font(.headline)
Spacer()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// Level Filter
Menu {
Button("All Levels") {
selectedLevel = nil
}
Divider()
ForEach(LogLevel.allCases, id: \.self) { level in
Button {
selectedLevel = level
} label: {
HStack {
Text(levelName(for: level))
if selectedLevel == level {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack {
Image(systemName: "slider.horizontal.3")
Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level")
Image(systemName: "chevron.down")
.font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
.foregroundColor(selectedLevel != nil ? .accentColor : .primary)
.clipShape(Capsule())
}
// Category Filter
Menu {
Button("All Categories") {
selectedCategory = nil
}
Divider()
ForEach(LogCategory.allCases, id: \.self) { category in
Button {
selectedCategory = category
} label: {
HStack {
Text(category.rawValue)
if selectedCategory == category {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack {
Image(systemName: "tag")
Text(selectedCategory?.rawValue ?? "Category")
Image(systemName: "chevron.down")
.font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
.foregroundColor(selectedCategory != nil ? .accentColor : .primary)
.clipShape(Capsule())
}
// Clear Filters
if selectedLevel != nil || selectedCategory != nil {
Button {
selectedLevel = nil
selectedCategory = nil
} label: {
HStack {
Image(systemName: "xmark.circle.fill")
Text("Clear")
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.clipShape(Capsule())
}
}
}
.padding(.horizontal)
}
}
.padding(.vertical, 8)
.background(Color(.systemGroupedBackground))
}
@ViewBuilder
private var loggingDisabledBanner: some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 4) {
Text("Logging Disabled")
.font(.headline)
.foregroundColor(.primary)
Text("Enable logging in settings to capture new logs")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button {
logConfig.isLoggingEnabled = true
} label: {
Text("Enable")
.font(.subheadline)
.fontWeight(.semibold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.orange)
.foregroundColor(.white)
.clipShape(Capsule())
}
}
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal)
.padding(.top, 8)
}
@ViewBuilder
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 60))
.foregroundColor(.secondary)
Text("No Logs Found")
.font(.title2)
.fontWeight(.semibold)
if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil {
Text("Try adjusting your filters or search criteria")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
searchText = ""
selectedLevel = nil
selectedCategory = nil
} label: {
Text("Clear Filters")
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.clipShape(Capsule())
}
} else {
Text("Logs will appear here as they are generated")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
private var filteredEntries: [LogEntry] {
var filtered = entries
if let level = selectedLevel {
filtered = filtered.filter { $0.level == level }
}
if let category = selectedCategory {
filtered = filtered.filter { $0.category == category }
}
if !searchText.isEmpty {
filtered = filtered.filter {
$0.message.localizedCaseInsensitiveContains(searchText) ||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
$0.function.localizedCaseInsensitiveContains(searchText)
}
}
return filtered
}
private func refreshLogs() async {
entries = await LogStore.shared.getEntries()
}
private func clearLogs() async {
await LogStore.shared.clear()
await refreshLogs()
logger.info("Cleared all debug logs")
}
private func exportLogs() async {
exportText = await LogStore.shared.exportAsText()
showShareSheet = true
logger.info("Exported debug logs")
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Debug"
case 1: return "Info"
case 2: return "Notice"
case 3: return "Warning"
case 4: return "Error"
case 5: return "Critical"
default: return "Unknown"
}
}
}
// MARK: - Log Entry Row
struct LogEntryRow: View {
let entry: LogEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
// Level Badge
Text(levelName(for: entry.level))
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(levelColor(for: entry.level).opacity(0.2))
.foregroundColor(levelColor(for: entry.level))
.clipShape(Capsule())
// Category
Text(entry.category.rawValue)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
// Timestamp
Text(entry.formattedTimestamp)
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
// Message
Text(entry.message)
.font(.subheadline)
.foregroundColor(.primary)
// Source Location
HStack(spacing: 4) {
Image(systemName: "doc.text")
.font(.caption2)
Text("\(entry.fileName):\(entry.line)")
.font(.caption)
Text("")
.font(.caption)
Text(entry.function)
.font(.caption)
}
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "DEBUG"
case 1: return "INFO"
case 2: return "NOTICE"
case 3: return "WARN"
case 4: return "ERROR"
case 5: return "CRITICAL"
default: return "UNKNOWN"
}
}
private func levelColor(for level: LogLevel) -> Color {
switch level.rawValue {
case 0: return .blue
case 1: return .green
case 2: return .cyan
case 3: return .orange
case 4: return .red
case 5: return .purple
default: return .gray
}
}
}
// MARK: - Activity View (for Share Sheet)
struct ActivityView: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {
NavigationStack {
DebugLogViewer()
}
}

View File

@ -9,82 +9,60 @@ import SwiftUI
struct FontSettingsView: View {
@State private var viewModel: FontSettingsViewModel
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
self.viewModel = viewModel
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Font Settings".localized, icon: "textformat")
.padding(.bottom, 4)
// Font Family Picker
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text("Font family")
.font(.headline)
Group {
Section {
Picker("Font family", selection: $viewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family)
}
}
.pickerStyle(MenuPickerStyle())
.onChange(of: viewModel.selectedFontFamily) {
Task {
await viewModel.saveFontSettings()
}
}
Picker("Font size", selection: $viewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
.pickerStyle(.segmented)
.onChange(of: viewModel.selectedFontSize) {
Task {
await viewModel.saveFontSettings()
}
}
} header: {
Text("Font Settings")
}
VStack(spacing: 16) {
// Font Size Picker
VStack(alignment: .leading, spacing: 8) {
Text("Font size")
.font(.headline)
Picker("Font size", selection: $viewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
.pickerStyle(SegmentedPickerStyle())
.onChange(of: viewModel.selectedFontSize) {
Task {
await viewModel.saveFontSettings()
}
}
}
// Font Preview
VStack(alignment: .leading, spacing: 8) {
Text("Preview")
.font(.caption)
Section {
VStack(alignment: .leading, spacing: 6) {
Text("readeck Bookmark Title")
.font(viewModel.previewTitleFont)
.fontWeight(.semibold)
.lineLimit(1)
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
.font(viewModel.previewBodyFont)
.lineLimit(3)
Text("12 min • Today • example.com")
.font(viewModel.previewCaptionFont)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 6) {
Text("readeck Bookmark Title")
.font(viewModel.previewTitleFont)
.fontWeight(.semibold)
.lineLimit(1)
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
.font(viewModel.previewBodyFont)
.lineLimit(3)
Text("12 min • Today • example.com")
.font(viewModel.previewCaptionFont)
.foregroundColor(.secondary)
}
.padding(4)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.vertical, 4)
} header: {
Text("Preview")
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
.task {
await viewModel.loadFontSettings()
}
@ -92,7 +70,10 @@ struct FontSettingsView: View {
}
#Preview {
FontSettingsView(viewModel: .init(
factory: MockUseCaseFactory())
)
List {
FontSettingsView(viewModel: .init(
factory: MockUseCaseFactory())
)
}
.listStyle(.insetGrouped)
}

View File

@ -3,110 +3,67 @@ 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
Group {
Section {
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))
} header: {
Text("Legal & Privacy")
}
Section {
Button(action: {
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
UIApplication.shared.open(url)
}
.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))
}) {
HStack {
Text(NSLocalizedString("Report an Issue", comment: ""))
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
Button(action: {
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text(NSLocalizedString("Contact Support", comment: ""))
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
} header: {
Text("Support")
}
}
.sheet(isPresented: $showingPrivacyPolicy) {
@ -119,7 +76,8 @@ struct LegalPrivacySettingsView: View {
}
#Preview {
LegalPrivacySettingsView()
.cardStyle()
.padding()
}
List {
LegalPrivacySettingsView()
}
.listStyle(.insetGrouped)
}

View File

@ -5,97 +5,83 @@
// Created by Ilyas Hallak on 16.08.25.
//
import SwiftUI
import os
struct LoggingConfigurationView: View {
@StateObject private var logConfig = LogConfiguration.shared
private let logger = Logger.ui
var body: some View {
NavigationView {
Form {
Section(header: Text("Global Settings")) {
VStack(alignment: .leading, spacing: 8) {
Text("Global Minimum Level")
.font(.headline)
Picker("Global Level", selection: $logConfig.globalMinLevel) {
ForEach(LogLevel.allCases, id: \.self) { level in
HStack {
Text(level.emoji)
Text(level.rawValue == 0 ? "Debug" :
level.rawValue == 1 ? "Info" :
level.rawValue == 2 ? "Notice" :
level.rawValue == 3 ? "Warning" :
level.rawValue == 4 ? "Error" : "Critical")
}
.tag(level)
}
List {
Section {
Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled)
.tint(.green)
} header: {
Text("Logging Status")
} footer: {
Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.")
}
if logConfig.isLoggingEnabled {
Section {
NavigationLink {
GlobalLogLevelView(logConfig: logConfig)
} label: {
HStack {
Label("Global Log Level", systemImage: "slider.horizontal.3")
Spacer()
Text(levelName(for: logConfig.globalMinLevel))
.foregroundColor(.secondary)
}
.pickerStyle(SegmentedPickerStyle())
Text("Logs below this level will be filtered out globally")
.font(.caption)
.foregroundColor(.secondary)
}
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
}
Section(header: Text("Category-specific Levels")) {
ForEach(LogCategory.allCases, id: \.self) { category in
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(category.rawValue)
.font(.headline)
Spacer()
Text(levelName(for: logConfig.getLevel(for: category)))
.font(.caption)
.foregroundColor(.secondary)
}
Picker("Level for \(category.rawValue)", selection: Binding(
get: { logConfig.getLevel(for: category) },
set: { logConfig.setLevel($0, for: category) }
)) {
ForEach(LogLevel.allCases, id: \.self) { level in
HStack {
Text(level.emoji)
Text(levelName(for: level))
}
.tag(level)
}
}
.pickerStyle(SegmentedPickerStyle())
}
.padding(.vertical, 4)
}
}
Section(header: Text("Reset")) {
Button("Reset to Defaults") {
resetToDefaults()
}
.foregroundColor(.orange)
}
Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) {
EmptyView()
} header: {
Text("Global Settings")
} footer: {
Text("Logs below the global level will be filtered out globally")
}
}
if logConfig.isLoggingEnabled {
Section {
ForEach(LogCategory.allCases, id: \.self) { category in
NavigationLink {
CategoryLogLevelView(category: category, logConfig: logConfig)
} label: {
HStack {
Text(category.rawValue)
Spacer()
Text(levelName(for: logConfig.getLevel(for: category)))
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Category-specific Levels")
} footer: {
Text("Configure log levels for each category individually")
}
}
Section {
Button(role: .destructive) {
resetToDefaults()
} label: {
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
}
}
.navigationTitle("Logging Configuration")
.navigationBarTitleDisplayMode(.inline)
}
.navigationTitle("Logging Configuration")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
logger.debug("Opened logging configuration view")
}
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Debug"
@ -107,25 +93,140 @@ struct LoggingConfigurationView: View {
default: return "Unknown"
}
}
private func resetToDefaults() {
logger.info("Resetting logging configuration to defaults")
// Reset all category levels (this will use globalMinLevel as fallback)
for category in LogCategory.allCases {
logConfig.setLevel(.debug, for: category)
}
// Reset global settings
logConfig.globalMinLevel = .debug
logConfig.showPerformanceLogs = true
logConfig.showTimestamps = true
logConfig.includeSourceLocation = true
logger.info("Logging configuration reset to defaults")
}
}
#Preview {
LoggingConfigurationView()
// MARK: - Global Log Level View
struct GlobalLogLevelView: View {
@ObservedObject var logConfig: LogConfiguration
var body: some View {
List {
ForEach(LogLevel.allCases, id: \.self) { level in
Button {
logConfig.globalMinLevel = level
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(levelName(for: level))
.foregroundColor(.primary)
Text(levelDescription(for: level))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if logConfig.globalMinLevel == level {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
.navigationTitle("Global Log Level")
.navigationBarTitleDisplayMode(.inline)
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Debug"
case 1: return "Info"
case 2: return "Notice"
case 3: return "Warning"
case 4: return "Error"
case 5: return "Critical"
default: return "Unknown"
}
}
private func levelDescription(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Show all logs including debug information"
case 1: return "Show informational messages and above"
case 2: return "Show notable events and above"
case 3: return "Show warnings and errors only"
case 4: return "Show errors and critical issues only"
case 5: return "Show only critical issues"
default: return ""
}
}
}
// MARK: - Category Log Level View
struct CategoryLogLevelView: View {
let category: LogCategory
@ObservedObject var logConfig: LogConfiguration
var body: some View {
List {
ForEach(LogLevel.allCases, id: \.self) { level in
Button {
logConfig.setLevel(level, for: category)
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(levelName(for: level))
.foregroundColor(.primary)
Text(levelDescription(for: level))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if logConfig.getLevel(for: category) == level {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
.navigationTitle("\(category.rawValue) Logs")
.navigationBarTitleDisplayMode(.inline)
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Debug"
case 1: return "Info"
case 2: return "Notice"
case 3: return "Warning"
case 4: return "Error"
case 5: return "Critical"
default: return "Unknown"
}
}
private func levelDescription(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "Show all logs including debug information"
case 1: return "Show informational messages and above"
case 2: return "Show notable events and above"
case 3: return "Show warnings and errors only"
case 4: return "Show errors and critical issues only"
case 5: return "Show only critical issues"
default: return ""
}
}
}
#Preview {
NavigationStack {
LoggingConfigurationView()
}
}

View File

@ -0,0 +1,35 @@
import SwiftUI
import MarkdownUI
/// A custom view that renders Markdown content using the MarkdownUI library.
/// This view encapsulates the Markdown rendering logic, making it easy to swap
/// the underlying Markdown library if needed in the future.
struct MarkdownContentView: View {
let content: String
var body: some View {
Markdown(content)
.textSelection(.enabled)
}
}
#Preview {
ScrollView {
MarkdownContentView(content: """
# Heading 1
This is a paragraph with **bold** and *italic* text.
## Heading 2
- List item 1
- List item 2
- List item 3
### Heading 3
Another paragraph with [a link](https://example.com).
""")
.padding()
}
}

View File

@ -1,56 +1,14 @@
import SwiftUI
extension AttributedString {
init(styledMarkdown markdownString: String) throws {
var output = try AttributedString(
markdown: markdownString,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
),
baseURL: nil
)
for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
guard let intentBlock = intentBlock else { continue }
for intent in intentBlock.components {
switch intent.kind {
case .header(level: let level):
switch level {
case 1:
output[intentRange].font = .system(.title).bold()
case 2:
output[intentRange].font = .system(.title2).bold()
case 3:
output[intentRange].font = .system(.title3).bold()
default:
break
}
default:
break
}
}
if intentRange.lowerBound != output.startIndex {
output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound)
}
}
self = output
}
}
struct ReleaseNotesView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let attributedString = loadReleaseNotes() {
Text(attributedString)
.textSelection(.enabled)
VStack(alignment: .leading, spacing: 0) {
if let markdownContent = loadReleaseNotes() {
MarkdownContentView(content: markdownContent)
.padding()
} else {
Text("Unable to load release notes")
@ -71,13 +29,12 @@ struct ReleaseNotesView: View {
}
}
private func loadReleaseNotes() -> AttributedString? {
private func loadReleaseNotes() -> String? {
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
let markdownContent = try? String(contentsOf: url),
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
let markdownContent = try? String(contentsOf: url) else {
return nil
}
return attributedString
return markdownContent
}
}

View File

@ -8,111 +8,90 @@
import SwiftUI
struct SettingsContainerView: View {
private var appVersion: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
return "v\(version) (\(build))"
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
FontSettingsView()
.cardStyle()
AppearanceSettingsView()
.cardStyle()
CacheSettingsView()
.cardStyle()
SettingsGeneralView()
.cardStyle()
SettingsServerView()
.cardStyle()
LegalPrivacySettingsView()
.cardStyle()
// Debug-only Logging Configuration
if Bundle.main.isDebugBuild {
debugSettingsSection
}
List {
AppearanceSettingsView()
CacheSettingsView()
SettingsGeneralView()
SettingsServerView()
LegalPrivacySettingsView()
// Debug-only Logging Configuration
#if DEBUG
if Bundle.main.isDebugBuild {
debugSettingsSection
}
.padding()
.background(Color(.systemGroupedBackground))
AppInfo()
Spacer()
#endif
// App Info Section
appInfoSection
}
.background(Color(.systemGroupedBackground))
.listStyle(.insetGrouped)
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large)
}
@ViewBuilder
private var debugSettingsSection: some View {
VStack(alignment: .leading, spacing: 16) {
Section {
SettingsRowNavigationLink(
icon: "list.bullet.rectangle",
iconColor: .blue,
title: "Debug Logs",
subtitle: "View all debug messages"
) {
DebugLogViewer()
}
SettingsRowNavigationLink(
icon: "slider.horizontal.3",
iconColor: .purple,
title: "Logging Configuration",
subtitle: "Configure log levels and categories"
) {
LoggingConfigurationView()
}
} header: {
HStack {
Image(systemName: "ant.fill")
.foregroundColor(.orange)
Text("Debug Settings")
.font(.headline)
.foregroundColor(.primary)
Spacer()
Text("DEBUG BUILD")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.foregroundColor(.orange)
.clipShape(Capsule())
}
NavigationLink {
LoggingConfigurationView()
} label: {
HStack {
Image(systemName: "doc.text.magnifyingglass")
.foregroundColor(.blue)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text("Logging Configuration")
.foregroundColor(.primary)
Text("Configure log levels and categories")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
}
}
@ViewBuilder
private var appInfoSection: some View {
Section {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
Text("Version \(appVersion)")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
.cardStyle()
}
@ViewBuilder
func AppInfo() -> some View {
VStack(spacing: 4) {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
Text("Version \(appVersion)")
.font(.footnote)
.foregroundColor(.secondary)
}
HStack(spacing: 8) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
HStack(spacing: 4) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
Text("Developer:")
.font(.footnote)
.foregroundColor(.secondary)
@ -123,26 +102,23 @@ struct SettingsContainerView: View {
}
.font(.footnote)
.foregroundColor(.blue)
.underline()
}
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundColor(.secondary)
Text("From Bremen with 💚")
.font(.footnote)
.foregroundColor(.secondary)
}
}
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundColor(.secondary)
Text("From Bremen with 💚")
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
.padding(.bottom, 4)
.multilineTextAlignment(.center)
.opacity(0.7)
}
}
// Card Modifier für einheitlichen Look
// Card Modifier für einheitlichen Look (kept for backwards compatibility with other views)
extension View {
func cardStyle() -> some View {
self
@ -154,5 +130,7 @@ extension View {
}
#Preview {
SettingsContainerView()
}
NavigationStack {
SettingsContainerView()
}
}

View File

@ -16,15 +16,8 @@ struct SettingsGeneralView: View {
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "General Settings".localized, icon: "gear")
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 12) {
Text("General")
.font(.headline)
// What's New Button
Group {
Section {
Button(action: {
showReleaseNotes = true
}) {
@ -39,83 +32,57 @@ struct SettingsGeneralView: View {
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
.toggleStyle(.switch)
.onChange(of: viewModel.enableTTS) {
Task {
await viewModel.saveGeneralSettings()
}
}
} header: {
Text("General")
} footer: {
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
.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) {
Text("Sync Settings")
.font(.headline)
Section {
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
.toggleStyle(SwitchToggleStyle())
if viewModel.autoSyncEnabled {
HStack {
Text("Sync interval")
Spacer()
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
}
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
}
} header: {
Text("Sync Settings")
}
// Reading Settings
VStack(alignment: .leading, spacing: 12) {
Text("Reading Settings")
.font(.headline)
Section {
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
.toggleStyle(SwitchToggleStyle())
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
.toggleStyle(SwitchToggleStyle())
} header: {
Text("Reading Settings")
}
// Messages
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
}
}
}
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
}
}
}
#endif
}
.sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView()
@ -127,7 +94,10 @@ struct SettingsGeneralView: View {
}
#Preview {
SettingsGeneralView(viewModel: .init(
MockUseCaseFactory()
))
List {
SettingsGeneralView(viewModel: .init(
MockUseCaseFactory()
))
}
.listStyle(.insetGrouped)
}

View File

@ -11,189 +11,33 @@ struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel()
@State private var showingLogoutAlert = false
init(showingLogoutAlert: Bool = false) {
self.showingLogoutAlert = showingLogoutAlert
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
.padding(.bottom, 4)
Section {
SettingsRowValue(
icon: "server.rack",
title: "Server",
value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint
)
Text(viewModel.isSetupMode ?
"Enter your Readeck server details to get started." :
"Your current server connection and login credentials.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
SettingsRowValue(
icon: "person.circle.fill",
title: "Username",
value: viewModel.username.isEmpty ? "Not set" : viewModel.username
)
// Form
VStack(spacing: 16) {
// Server Endpoint
VStack(alignment: .leading, spacing: 8) {
if viewModel.isSetupMode {
TextField("",
text: $viewModel.endpoint,
prompt: Text("Server Endpoint").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.endpoint) {
viewModel.clearMessages()
}
// Quick Input Chips
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
QuickInputChip(text: "http://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "http://" + viewModel.endpoint
}
})
QuickInputChip(text: "https://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "https://" + viewModel.endpoint
}
})
QuickInputChip(text: "192.168.", action: {
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
if viewModel.endpoint.starts(with: "http") {
viewModel.endpoint += "192.168."
} else {
viewModel.endpoint = "http://192.168."
}
}
})
QuickInputChip(text: ":8000", action: {
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
viewModel.endpoint += ":8000"
}
})
}
.padding(.horizontal, 1)
}
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
HStack {
Image(systemName: "server.rack")
.foregroundColor(.accentColor)
Text(viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint)
.foregroundColor(viewModel.endpoint.isEmpty ? .secondary : .primary)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
// Username
VStack(alignment: .leading, spacing: 8) {
if viewModel.isSetupMode {
TextField("",
text: $viewModel.username,
prompt: Text("Username").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: viewModel.username) {
viewModel.clearMessages()
}
} else {
HStack {
Image(systemName: "person.circle.fill")
.foregroundColor(.accentColor)
Text(viewModel.username.isEmpty ? "Not set" : viewModel.username)
.foregroundColor(viewModel.username.isEmpty ? .secondary : .primary)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
// Password
if viewModel.isSetupMode {
VStack(alignment: .leading, spacing: 8) {
SecureField("",
text: $viewModel.password,
prompt: Text("Password").foregroundColor(.secondary))
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.password) {
viewModel.clearMessages()
}
}
}
}
// Messages
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
}
}
if viewModel.isSetupMode {
VStack(spacing: 10) {
Button(action: {
Task {
await viewModel.saveServerSettings()
}
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canLogin || viewModel.isLoading)
}
} else {
Button(action: {
showingLogoutAlert = true
}) {
HStack(spacing: 6) {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.caption)
Text("Logout")
.font(.caption)
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(8)
}
SettingsRowButton(
icon: "rectangle.portrait.and.arrow.right",
iconColor: .red,
title: "Logout",
subtitle: nil,
destructive: true
) {
showingLogoutAlert = true
}
} header: {
Text("Server Connection")
} footer: {
Text("Your current server connection and login credentials.")
}
.alert("Logout", isPresented: $showingLogoutAlert) {
Button("Cancel", role: .cancel) { }
@ -211,22 +55,9 @@ struct SettingsServerView: View {
}
}
// MARK: - Quick Input Chip Component
struct QuickInputChip: View {
let text: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(text)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(12)
}
#Preview {
List {
SettingsServerView()
}
.listStyle(.insetGrouped)
}

View File

@ -44,4 +44,12 @@ struct URLUtil {
guard let url = URL(string: urlString), let host = url.host else { return nil }
return host.replacingOccurrences(of: "www.", with: "")
}
static func openUrlLabel(for urlString: String) -> String {
if let domain = extractDomain(from: urlString) {
return String(format: "open_url".localized, domain)
} else {
return "open_original_page".localized
}
}
}

View File

@ -20,7 +20,7 @@ struct readeckApp: App {
if appViewModel.hasFinishedSetup {
MainTabView()
} else {
SettingsServerView()
OnboardingServerView()
.padding()
}
}

View File

@ -0,0 +1,145 @@
//
// LogStore.swift
// readeck
//
// Created by Ilyas Hallak on 01.11.25.
//
import Foundation
// MARK: - Log Entry
struct LogEntry: Identifiable, Codable {
let id: UUID
let timestamp: Date
let level: LogLevel
let category: LogCategory
let message: String
let file: String
let function: String
let line: Int
var fileName: String {
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
}
var formattedTimestamp: String {
DateFormatter.logTimestamp.string(from: timestamp)
}
init(
id: UUID = UUID(),
timestamp: Date = Date(),
level: LogLevel,
category: LogCategory,
message: String,
file: String,
function: String,
line: Int
) {
self.id = id
self.timestamp = timestamp
self.level = level
self.category = category
self.message = message
self.file = file
self.function = function
self.line = line
}
}
// MARK: - Log Store
actor LogStore {
static let shared = LogStore()
private var entries: [LogEntry] = []
private let maxEntries: Int
private init(maxEntries: Int = 1000) {
self.maxEntries = maxEntries
}
func addEntry(_ entry: LogEntry) {
entries.append(entry)
// Keep only the most recent entries
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
}
func getEntries() -> [LogEntry] {
return entries
}
func getEntries(
level: LogLevel? = nil,
category: LogCategory? = nil,
searchText: String? = nil
) -> [LogEntry] {
var filtered = entries
if let level = level {
filtered = filtered.filter { $0.level == level }
}
if let category = category {
filtered = filtered.filter { $0.category == category }
}
if let searchText = searchText, !searchText.isEmpty {
filtered = filtered.filter {
$0.message.localizedCaseInsensitiveContains(searchText) ||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
$0.function.localizedCaseInsensitiveContains(searchText)
}
}
return filtered
}
func clear() {
entries.removeAll()
}
func exportAsText() -> String {
var text = "Readeck Debug Logs\n"
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
text += "Total Entries: \(entries.count)\n"
text += String(repeating: "=", count: 80) + "\n\n"
for entry in entries {
text += "[\(entry.formattedTimestamp)] "
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
text += "[\(entry.category.rawValue)] "
text += "\(entry.fileName):\(entry.line) "
text += "\(entry.function)\n"
text += " \(entry.message)\n\n"
}
return text
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "DEBUG"
case 1: return "INFO"
case 2: return "NOTICE"
case 3: return "WARNING"
case 4: return "ERROR"
case 5: return "CRITICAL"
default: return "UNKNOWN"
}
}
}
// MARK: - DateFormatter Extension
extension DateFormatter {
static let exportTimestamp: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}

View File

@ -10,14 +10,14 @@ import os
// MARK: - Log Configuration
enum LogLevel: Int, CaseIterable {
enum LogLevel: Int, CaseIterable, Codable {
case debug = 0
case info = 1
case notice = 2
case warning = 3
case error = 4
case critical = 5
var emoji: String {
switch self {
case .debug: return "🔍"
@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable {
}
}
enum LogCategory: String, CaseIterable {
enum LogCategory: String, CaseIterable, Codable {
case network = "Network"
case ui = "UI"
case data = "Data"
@ -43,13 +43,14 @@ enum LogCategory: String, CaseIterable {
class LogConfiguration: ObservableObject {
static let shared = LogConfiguration()
@Published private var categoryLevels: [LogCategory: LogLevel] = [:]
@Published var globalMinLevel: LogLevel = .debug
@Published var showPerformanceLogs = true
@Published var showTimestamps = true
@Published var includeSourceLocation = true
@Published var isLoggingEnabled = false
private init() {
loadConfiguration()
}
@ -64,6 +65,7 @@ class LogConfiguration: ObservableObject {
}
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
guard isLoggingEnabled else { return false }
let categoryLevel = getLevel(for: category)
return level.rawValue >= categoryLevel.rawValue
}
@ -84,6 +86,7 @@ class LogConfiguration: ObservableObject {
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled")
}
private func saveConfiguration() {
@ -96,6 +99,7 @@ class LogConfiguration: ObservableObject {
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled")
}
}
@ -110,41 +114,66 @@ struct Logger {
}
// MARK: - Log Levels
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.debug, for: category) else { return }
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
logger.debug("\(formattedMessage)")
storeLog(message: message, level: .debug, file: file, function: function, line: line)
}
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.info, for: category) else { return }
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
logger.info("\(formattedMessage)")
storeLog(message: message, level: .info, file: file, function: function, line: line)
}
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.notice, for: category) else { return }
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
logger.notice("\(formattedMessage)")
storeLog(message: message, level: .notice, file: file, function: function, line: line)
}
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.warning, for: category) else { return }
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
logger.warning("\(formattedMessage)")
storeLog(message: message, level: .warning, file: file, function: function, line: line)
}
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.error, for: category) else { return }
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
logger.error("\(formattedMessage)")
storeLog(message: message, level: .error, file: file, function: function, line: line)
}
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.critical, for: category) else { return }
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
logger.critical("\(formattedMessage)")
storeLog(message: message, level: .critical, file: file, function: function, line: line)
}
// MARK: - Store Log
private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) {
#if DEBUG
guard config.isLoggingEnabled else { return }
let entry = LogEntry(
level: level,
category: category,
message: message,
file: file,
function: function,
line: line
)
Task {
await LogStore.shared.addEntry(entry)
}
#endif
}
// MARK: - Convenience Methods