Compare commits
10 Commits
202eba48f3
...
af93c0e79a
| Author | SHA1 | Date | |
|---|---|---|---|
| af93c0e79a | |||
| a3825271f4 | |||
| 6545e4c57b | |||
| 8bd39295c6 | |||
| 5f44c8631b | |||
| c2d7989be9 | |||
| 9e0ebbe0d7 | |||
| 7c5f68e174 | |||
| 0ce3e45d7b | |||
| 7cac529c7e |
@ -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" */;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -21,4 +21,8 @@ class AnnotationsRepository: PAnnotationsRepository {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
||||
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
protocol PAnnotationsRepository {
|
||||
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
||||
}
|
||||
|
||||
17
readeck/Domain/UseCase/DeleteAnnotationUseCase.swift
Normal file
17
readeck/Domain/UseCase/DeleteAnnotationUseCase.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
protocol PDeleteAnnotationUseCase {
|
||||
func execute(bookmarkId: String, annotationId: String) async throws
|
||||
}
|
||||
|
||||
class DeleteAnnotationUseCase: PDeleteAnnotationUseCase {
|
||||
private let repository: PAnnotationsRepository
|
||||
|
||||
init(repository: PAnnotationsRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func execute(bookmarkId: String, annotationId: String) async throws {
|
||||
try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||
}
|
||||
}
|
||||
@ -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.";
|
||||
|
||||
@ -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.";
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
308
readeck/UI/Components/SettingsRow.swift
Normal file
308
readeck/UI/Components/SettingsRow.swift
Normal file
@ -0,0 +1,308 @@
|
||||
//
|
||||
// SettingsRow.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 31.10.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Settings Row with Navigation Link
|
||||
struct SettingsRowNavigationLink<Destination: View>: View {
|
||||
let icon: String?
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let destination: Destination
|
||||
|
||||
init(
|
||||
icon: String? = nil,
|
||||
iconColor: Color = .accentColor,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
@ViewBuilder destination: () -> Destination
|
||||
) {
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.destination = destination()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(destination: destination) {
|
||||
SettingsRowLabel(
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
title: title,
|
||||
subtitle: subtitle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Row with Toggle
|
||||
struct SettingsRowToggle: View {
|
||||
let icon: String?
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
@Binding var isOn: Bool
|
||||
|
||||
init(
|
||||
icon: String? = nil,
|
||||
iconColor: Color = .accentColor,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
isOn: Binding<Bool>
|
||||
) {
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self._isOn = isOn
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
SettingsRowLabel(
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
title: title,
|
||||
subtitle: subtitle
|
||||
)
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Row with Value Display
|
||||
struct SettingsRowValue: View {
|
||||
let icon: String?
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let value: String
|
||||
let valueColor: Color
|
||||
|
||||
init(
|
||||
icon: String? = nil,
|
||||
iconColor: Color = .accentColor,
|
||||
title: String,
|
||||
value: String,
|
||||
valueColor: Color = .secondary
|
||||
) {
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.valueColor = valueColor
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
SettingsRowLabel(
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
title: title,
|
||||
subtitle: nil
|
||||
)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundColor(valueColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Row Button (for actions)
|
||||
struct SettingsRowButton: View {
|
||||
let icon: String?
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let destructive: Bool
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
icon: String? = nil,
|
||||
iconColor: Color = .accentColor,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
destructive: Bool = false,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.destructive = destructive
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
SettingsRowLabel(
|
||||
icon: icon,
|
||||
iconColor: destructive ? .red : iconColor,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
titleColor: destructive ? .red : .primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Row with Picker
|
||||
struct SettingsRowPicker<T: Hashable>: View {
|
||||
let icon: String?
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let selection: Binding<T>
|
||||
let options: [(value: T, label: String)]
|
||||
|
||||
init(
|
||||
icon: String? = nil,
|
||||
iconColor: Color = .accentColor,
|
||||
title: String,
|
||||
selection: Binding<T>,
|
||||
options: [(value: T, label: String)]
|
||||
) {
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.title = title
|
||||
self.selection = selection
|
||||
self.options = options
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
SettingsRowLabel(
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
title: title,
|
||||
subtitle: nil
|
||||
)
|
||||
Spacer()
|
||||
Picker("", selection: selection) {
|
||||
ForEach(options, id: \.value) { option in
|
||||
Text(option.label).tag(option.value)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Row Label (internal component)
|
||||
struct SettingsRowLabel: View {
|
||||
let icon: String?
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let titleColor: Color
|
||||
|
||||
init(
|
||||
icon: String?,
|
||||
iconColor: Color,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
titleColor: Color = .primary
|
||||
) {
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.titleColor = titleColor
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(iconColor)
|
||||
.frame(width: 24)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.foregroundColor(titleColor)
|
||||
|
||||
if let subtitle = subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
#Preview("Navigation Link") {
|
||||
List {
|
||||
SettingsRowNavigationLink(
|
||||
icon: "paintbrush",
|
||||
title: "App Icon",
|
||||
subtitle: nil
|
||||
) {
|
||||
Text("Detail View")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
#Preview("Toggle") {
|
||||
List {
|
||||
SettingsRowToggle(
|
||||
icon: "speaker.wave.2",
|
||||
title: "Read Aloud Feature",
|
||||
subtitle: "Text-to-Speech functionality",
|
||||
isOn: .constant(true)
|
||||
)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
#Preview("Value Display") {
|
||||
List {
|
||||
SettingsRowValue(
|
||||
icon: "paintbrush.fill",
|
||||
iconColor: .purple,
|
||||
title: "Tint Color",
|
||||
value: "Purple"
|
||||
)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
#Preview("Button") {
|
||||
List {
|
||||
SettingsRowButton(
|
||||
icon: "trash",
|
||||
iconColor: .red,
|
||||
title: "Clear Cache",
|
||||
subtitle: "Remove all cached images",
|
||||
destructive: true
|
||||
) {
|
||||
print("Clear cache tapped")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
#Preview("Picker") {
|
||||
List {
|
||||
SettingsRowPicker(
|
||||
icon: "textformat",
|
||||
title: "Font Family",
|
||||
selection: .constant("System"),
|
||||
options: [
|
||||
("System", "System"),
|
||||
("Serif", "Serif"),
|
||||
("Monospace", "Monospace")
|
||||
]
|
||||
)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
175
readeck/UI/Onboarding/OnboardingServerView.swift
Normal file
175
readeck/UI/Onboarding/OnboardingServerView.swift
Normal file
@ -0,0 +1,175 @@
|
||||
//
|
||||
// OnboardingServerView.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 31.10.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingServerView: View {
|
||||
@State private var viewModel = SettingsServerViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: "Server Settings".localized, icon: "server.rack")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Text("Enter your Readeck server details to get started.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Form
|
||||
VStack(spacing: 16) {
|
||||
// Server Endpoint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("",
|
||||
text: $viewModel.endpoint,
|
||||
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.onChange(of: viewModel.endpoint) {
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
|
||||
// Quick Input Chips
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
QuickInputChip(text: "http://", action: {
|
||||
if !viewModel.endpoint.starts(with: "http") {
|
||||
viewModel.endpoint = "http://" + viewModel.endpoint
|
||||
}
|
||||
})
|
||||
QuickInputChip(text: "https://", action: {
|
||||
if !viewModel.endpoint.starts(with: "http") {
|
||||
viewModel.endpoint = "https://" + viewModel.endpoint
|
||||
}
|
||||
})
|
||||
QuickInputChip(text: "192.168.", action: {
|
||||
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
|
||||
if viewModel.endpoint.starts(with: "http") {
|
||||
viewModel.endpoint += "192.168."
|
||||
} else {
|
||||
viewModel.endpoint = "http://192.168."
|
||||
}
|
||||
}
|
||||
})
|
||||
QuickInputChip(text: ":8000", action: {
|
||||
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
|
||||
viewModel.endpoint += ":8000"
|
||||
}
|
||||
})
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
|
||||
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Username
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("",
|
||||
text: $viewModel.username,
|
||||
prompt: Text("Username").foregroundColor(.secondary))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.onChange(of: viewModel.username) {
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
}
|
||||
|
||||
// Password
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SecureField("",
|
||||
text: $viewModel.password,
|
||||
prompt: Text("Password").foregroundColor(.secondary))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: viewModel.password) {
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
if let successMessage = viewModel.successMessage {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text(successMessage)
|
||||
.foregroundColor(.green)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.saveServerSettings()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadServerSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick Input Chip Component
|
||||
|
||||
struct QuickInputChip: View {
|
||||
let text: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemGray5))
|
||||
.foregroundColor(.secondary)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingServerView()
|
||||
.padding()
|
||||
}
|
||||
@ -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
|
||||
|
||||
---
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
171
readeck/UI/Settings/CardLayoutSelectionView.swift
Normal file
171
readeck/UI/Settings/CardLayoutSelectionView.swift
Normal file
@ -0,0 +1,171 @@
|
||||
//
|
||||
// CardLayoutSelectionView.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 31.10.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CardLayoutSelectionView: View {
|
||||
@Binding var selectedCardLayout: CardLayoutStyle
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let onSave: () -> Void
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
||||
CardLayoutPreview(
|
||||
layout: layout,
|
||||
isSelected: selectedCardLayout == layout
|
||||
) {
|
||||
selectedCardLayout = layout
|
||||
onSave()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Card Layout")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
struct CardLayoutPreview: View {
|
||||
let layout: CardLayoutStyle
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: 12) {
|
||||
// Visual Preview
|
||||
switch layout {
|
||||
case .compact:
|
||||
// Compact: Small image on left, content on right
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.8))
|
||||
.frame(height: 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.6))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: 60)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.4))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: 40)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: 80, height: 50)
|
||||
|
||||
case .magazine:
|
||||
VStack(spacing: 4) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(height: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.8))
|
||||
.frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.6))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: 40)
|
||||
|
||||
Text("Fixed 140px")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.padding(6)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: 80, height: 65)
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
|
||||
case .natural:
|
||||
VStack(spacing: 3) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(height: 38)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.8))
|
||||
.frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.primary.opacity(0.6))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: 35)
|
||||
|
||||
Text("Original ratio")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.padding(6)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: 80, height: 75)
|
||||
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(layout.displayName)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(layout.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
.font(.title2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
CardLayoutSelectionView(
|
||||
selectedCardLayout: .constant(.magazine),
|
||||
onSave: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
444
readeck/UI/Settings/DebugLogViewer.swift
Normal file
444
readeck/UI/Settings/DebugLogViewer.swift
Normal file
@ -0,0 +1,444 @@
|
||||
//
|
||||
// DebugLogViewer.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 01.11.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DebugLogViewer: View {
|
||||
@State private var entries: [LogEntry] = []
|
||||
@State private var selectedLevel: LogLevel?
|
||||
@State private var selectedCategory: LogCategory?
|
||||
@State private var searchText = ""
|
||||
@State private var showShareSheet = false
|
||||
@State private var exportText = ""
|
||||
@State private var autoScroll = true
|
||||
@State private var showFilters = false
|
||||
@StateObject private var logConfig = LogConfiguration.shared
|
||||
|
||||
private let logger = Logger.ui
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Logging Disabled Warning
|
||||
if !logConfig.isLoggingEnabled {
|
||||
loggingDisabledBanner
|
||||
}
|
||||
|
||||
// Filter Bar
|
||||
if showFilters {
|
||||
filterBar
|
||||
}
|
||||
|
||||
// Log List
|
||||
if filteredEntries.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(filteredEntries) { entry in
|
||||
LogEntryRow(entry: entry)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: entries.count) { oldValue, newValue in
|
||||
if autoScroll, let lastEntry = filteredEntries.last {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastEntry.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Debug Logs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
showFilters.toggle()
|
||||
} label: {
|
||||
Label(
|
||||
showFilters ? "Hide Filters" : "Show Filters",
|
||||
systemImage: "line.3.horizontal.decrease.circle"
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
autoScroll.toggle()
|
||||
} label: {
|
||||
Label(
|
||||
autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll",
|
||||
systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle"
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await refreshLogs()
|
||||
}
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await exportLogs()
|
||||
}
|
||||
} label: {
|
||||
Label("Export Logs", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await clearLogs()
|
||||
}
|
||||
} label: {
|
||||
Label("Clear All Logs", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search logs")
|
||||
.task {
|
||||
await refreshLogs()
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
ActivityView(activityItems: [exportText])
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var filterBar: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("Filters")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
// Level Filter
|
||||
Menu {
|
||||
Button("All Levels") {
|
||||
selectedLevel = nil
|
||||
}
|
||||
Divider()
|
||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||
Button {
|
||||
selectedLevel = level
|
||||
} label: {
|
||||
HStack {
|
||||
Text(levelName(for: level))
|
||||
if selectedLevel == level {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level")
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
||||
.foregroundColor(selectedLevel != nil ? .accentColor : .primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
// Category Filter
|
||||
Menu {
|
||||
Button("All Categories") {
|
||||
selectedCategory = nil
|
||||
}
|
||||
Divider()
|
||||
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||
Button {
|
||||
selectedCategory = category
|
||||
} label: {
|
||||
HStack {
|
||||
Text(category.rawValue)
|
||||
if selectedCategory == category {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "tag")
|
||||
Text(selectedCategory?.rawValue ?? "Category")
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
||||
.foregroundColor(selectedCategory != nil ? .accentColor : .primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
// Clear Filters
|
||||
if selectedLevel != nil || selectedCategory != nil {
|
||||
Button {
|
||||
selectedLevel = nil
|
||||
selectedCategory = nil
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
Text("Clear")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemGray5))
|
||||
.foregroundColor(.secondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loggingDisabledBanner: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
.font(.title3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Logging Disabled")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Enable logging in settings to capture new logs")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
logConfig.isLoggingEnabled = true
|
||||
} label: {
|
||||
Text("Enable")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.orange)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "doc.text.magnifyingglass")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Logs Found")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil {
|
||||
Text("Try adjusting your filters or search criteria")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
searchText = ""
|
||||
selectedLevel = nil
|
||||
selectedCategory = nil
|
||||
} label: {
|
||||
Text("Clear Filters")
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
} else {
|
||||
Text("Logs will appear here as they are generated")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var filteredEntries: [LogEntry] {
|
||||
var filtered = entries
|
||||
|
||||
if let level = selectedLevel {
|
||||
filtered = filtered.filter { $0.level == level }
|
||||
}
|
||||
|
||||
if let category = selectedCategory {
|
||||
filtered = filtered.filter { $0.category == category }
|
||||
}
|
||||
|
||||
if !searchText.isEmpty {
|
||||
filtered = filtered.filter {
|
||||
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
private func refreshLogs() async {
|
||||
entries = await LogStore.shared.getEntries()
|
||||
}
|
||||
|
||||
private func clearLogs() async {
|
||||
await LogStore.shared.clear()
|
||||
await refreshLogs()
|
||||
logger.info("Cleared all debug logs")
|
||||
}
|
||||
|
||||
private func exportLogs() async {
|
||||
exportText = await LogStore.shared.exportAsText()
|
||||
showShareSheet = true
|
||||
logger.info("Exported debug logs")
|
||||
}
|
||||
|
||||
private func levelName(for level: LogLevel) -> String {
|
||||
switch level.rawValue {
|
||||
case 0: return "Debug"
|
||||
case 1: return "Info"
|
||||
case 2: return "Notice"
|
||||
case 3: return "Warning"
|
||||
case 4: return "Error"
|
||||
case 5: return "Critical"
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log Entry Row
|
||||
|
||||
struct LogEntryRow: View {
|
||||
let entry: LogEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
// Level Badge
|
||||
Text(levelName(for: entry.level))
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(levelColor(for: entry.level).opacity(0.2))
|
||||
.foregroundColor(levelColor(for: entry.level))
|
||||
.clipShape(Capsule())
|
||||
|
||||
// Category
|
||||
Text(entry.category.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Timestamp
|
||||
Text(entry.formattedTimestamp)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
// Message
|
||||
Text(entry.message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
// Source Location
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.caption2)
|
||||
Text("\(entry.fileName):\(entry.line)")
|
||||
.font(.caption)
|
||||
Text("•")
|
||||
.font(.caption)
|
||||
Text(entry.function)
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func levelName(for level: LogLevel) -> String {
|
||||
switch level.rawValue {
|
||||
case 0: return "DEBUG"
|
||||
case 1: return "INFO"
|
||||
case 2: return "NOTICE"
|
||||
case 3: return "WARN"
|
||||
case 4: return "ERROR"
|
||||
case 5: return "CRITICAL"
|
||||
default: return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
private func levelColor(for level: LogLevel) -> Color {
|
||||
switch level.rawValue {
|
||||
case 0: return .blue
|
||||
case 1: return .green
|
||||
case 2: return .cyan
|
||||
case 3: return .orange
|
||||
case 4: return .red
|
||||
case 5: return .purple
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activity View (for Share Sheet)
|
||||
|
||||
struct ActivityView: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
DebugLogViewer()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
35
readeck/UI/Settings/MarkdownContentView.swift
Normal file
35
readeck/UI/Settings/MarkdownContentView.swift
Normal file
@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
import MarkdownUI
|
||||
|
||||
/// A custom view that renders Markdown content using the MarkdownUI library.
|
||||
/// This view encapsulates the Markdown rendering logic, making it easy to swap
|
||||
/// the underlying Markdown library if needed in the future.
|
||||
struct MarkdownContentView: View {
|
||||
let content: String
|
||||
|
||||
var body: some View {
|
||||
Markdown(content)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ScrollView {
|
||||
MarkdownContentView(content: """
|
||||
# Heading 1
|
||||
|
||||
This is a paragraph with **bold** and *italic* text.
|
||||
|
||||
## Heading 2
|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
- List item 3
|
||||
|
||||
### Heading 3
|
||||
|
||||
Another paragraph with [a link](https://example.com).
|
||||
""")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ struct readeckApp: App {
|
||||
if appViewModel.hasFinishedSetup {
|
||||
MainTabView()
|
||||
} else {
|
||||
SettingsServerView()
|
||||
OnboardingServerView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
145
readeck/Utils/LogStore.swift
Normal file
145
readeck/Utils/LogStore.swift
Normal file
@ -0,0 +1,145 @@
|
||||
//
|
||||
// LogStore.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 01.11.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Log Entry
|
||||
|
||||
struct LogEntry: Identifiable, Codable {
|
||||
let id: UUID
|
||||
let timestamp: Date
|
||||
let level: LogLevel
|
||||
let category: LogCategory
|
||||
let message: String
|
||||
let file: String
|
||||
let function: String
|
||||
let line: Int
|
||||
|
||||
var fileName: String {
|
||||
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
||||
}
|
||||
|
||||
var formattedTimestamp: String {
|
||||
DateFormatter.logTimestamp.string(from: timestamp)
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
timestamp: Date = Date(),
|
||||
level: LogLevel,
|
||||
category: LogCategory,
|
||||
message: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: Int
|
||||
) {
|
||||
self.id = id
|
||||
self.timestamp = timestamp
|
||||
self.level = level
|
||||
self.category = category
|
||||
self.message = message
|
||||
self.file = file
|
||||
self.function = function
|
||||
self.line = line
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log Store
|
||||
|
||||
actor LogStore {
|
||||
static let shared = LogStore()
|
||||
|
||||
private var entries: [LogEntry] = []
|
||||
private let maxEntries: Int
|
||||
|
||||
private init(maxEntries: Int = 1000) {
|
||||
self.maxEntries = maxEntries
|
||||
}
|
||||
|
||||
func addEntry(_ entry: LogEntry) {
|
||||
entries.append(entry)
|
||||
|
||||
// Keep only the most recent entries
|
||||
if entries.count > maxEntries {
|
||||
entries.removeFirst(entries.count - maxEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func getEntries() -> [LogEntry] {
|
||||
return entries
|
||||
}
|
||||
|
||||
func getEntries(
|
||||
level: LogLevel? = nil,
|
||||
category: LogCategory? = nil,
|
||||
searchText: String? = nil
|
||||
) -> [LogEntry] {
|
||||
var filtered = entries
|
||||
|
||||
if let level = level {
|
||||
filtered = filtered.filter { $0.level == level }
|
||||
}
|
||||
|
||||
if let category = category {
|
||||
filtered = filtered.filter { $0.category == category }
|
||||
}
|
||||
|
||||
if let searchText = searchText, !searchText.isEmpty {
|
||||
filtered = filtered.filter {
|
||||
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func clear() {
|
||||
entries.removeAll()
|
||||
}
|
||||
|
||||
func exportAsText() -> String {
|
||||
var text = "Readeck Debug Logs\n"
|
||||
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
|
||||
text += "Total Entries: \(entries.count)\n"
|
||||
text += String(repeating: "=", count: 80) + "\n\n"
|
||||
|
||||
for entry in entries {
|
||||
text += "[\(entry.formattedTimestamp)] "
|
||||
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
|
||||
text += "[\(entry.category.rawValue)] "
|
||||
text += "\(entry.fileName):\(entry.line) "
|
||||
text += "\(entry.function)\n"
|
||||
text += " \(entry.message)\n\n"
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
private func levelName(for level: LogLevel) -> String {
|
||||
switch level.rawValue {
|
||||
case 0: return "DEBUG"
|
||||
case 1: return "INFO"
|
||||
case 2: return "NOTICE"
|
||||
case 3: return "WARNING"
|
||||
case 4: return "ERROR"
|
||||
case 5: return "CRITICAL"
|
||||
default: return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateFormatter Extension
|
||||
|
||||
extension DateFormatter {
|
||||
static let exportTimestamp: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
@ -10,14 +10,14 @@ import os
|
||||
|
||||
// MARK: - Log Configuration
|
||||
|
||||
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
|
||||
Loading…
x
Reference in New Issue
Block a user