Compare commits

..

No commits in common. "af93c0e79aaee89cadb8a4c754b818109a5e86c4" and "202eba48f3713905e793620c2c96c7729b74d5b4" have entirely different histories.

36 changed files with 918 additions and 2152 deletions

View File

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

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635", "originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457",
"pins" : [ "pins" : [
{ {
"identity" : "kingfisher", "identity" : "kingfisher",
@ -19,15 +19,6 @@
"version" : "1.21.0" "version" : "1.21.0"
} }
}, },
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{ {
"identity" : "r.swift", "identity" : "r.swift",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@ -46,24 +37,6 @@
"version" : "1.6.1" "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", "identity" : "xcodeedit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@ -20,7 +20,6 @@ protocol PAPI {
func getBookmarkLabels() async throws -> [BookmarkLabelDto] func getBookmarkLabels() async throws -> [BookmarkLabelDto]
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] 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 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 { class API: PAPI {
@ -487,44 +486,6 @@ class API: PAPI {
logger.info("Successfully created annotation for bookmark: \(bookmarkId)") logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
return result 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 { enum HTTPMethod: String {

View File

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

View File

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

View File

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

View File

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

View File

@ -101,8 +101,6 @@
"More" = "More"; "More" = "More";
"New Bookmark" = "New Bookmark"; "New Bookmark" = "New Bookmark";
"No articles in the queue" = "No articles in the queue"; "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" = "No bookmarks";
"No bookmarks found in %@." = "No bookmarks found in %@."; "No bookmarks found in %@." = "No bookmarks found in %@.";
"No bookmarks found." = "No bookmarks found."; "No bookmarks found." = "No bookmarks found.";

View File

@ -10,7 +10,7 @@ import os
// MARK: - Log Configuration // MARK: - Log Configuration
enum LogLevel: Int, CaseIterable, Codable { enum LogLevel: Int, CaseIterable {
case debug = 0 case debug = 0
case info = 1 case info = 1
case notice = 2 case notice = 2
@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable, Codable {
} }
} }
enum LogCategory: String, CaseIterable, Codable { enum LogCategory: String, CaseIterable {
case network = "Network" case network = "Network"
case ui = "UI" case ui = "UI"
case data = "Data" case data = "Data"
@ -49,7 +49,6 @@ class LogConfiguration: ObservableObject {
@Published var showPerformanceLogs = true @Published var showPerformanceLogs = true
@Published var showTimestamps = true @Published var showTimestamps = true
@Published var includeSourceLocation = true @Published var includeSourceLocation = true
@Published var isLoggingEnabled = false
private init() { private init() {
loadConfiguration() loadConfiguration()
@ -65,7 +64,6 @@ class LogConfiguration: ObservableObject {
} }
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool { func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
guard isLoggingEnabled else { return false }
let categoryLevel = getLevel(for: category) let categoryLevel = getLevel(for: category)
return level.rawValue >= categoryLevel.rawValue return level.rawValue >= categoryLevel.rawValue
} }
@ -86,7 +84,6 @@ class LogConfiguration: ObservableObject {
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance") showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps") showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation") includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled")
} }
private func saveConfiguration() { private func saveConfiguration() {
@ -99,7 +96,6 @@ class LogConfiguration: ObservableObject {
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance") UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps") UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation") UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled")
} }
} }
@ -119,61 +115,36 @@ struct Logger {
guard config.shouldLog(.debug, for: category) else { return } guard config.shouldLog(.debug, for: category) else { return }
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
logger.debug("\(formattedMessage)") 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) { func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.info, for: category) else { return } guard config.shouldLog(.info, for: category) else { return }
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
logger.info("\(formattedMessage)") 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) { func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.notice, for: category) else { return } guard config.shouldLog(.notice, for: category) else { return }
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
logger.notice("\(formattedMessage)") 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) { func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.warning, for: category) else { return } guard config.shouldLog(.warning, for: category) else { return }
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
logger.warning("\(formattedMessage)") 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) { func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.error, for: category) else { return } guard config.shouldLog(.error, for: category) else { return }
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
logger.error("\(formattedMessage)") 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) { func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.critical, for: category) else { return } guard config.shouldLog(.critical, for: category) else { return }
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line) let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
logger.critical("\(formattedMessage)") 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 // MARK: - Convenience Methods

View File

@ -18,17 +18,22 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi
### Performance Improvements ### Performance Improvements
- **Dramatically faster label loading** - especially with 1000+ labels - **Dramatically faster label loading** - especially with 1000+ labels
- Labels now load instantly, even without internet connection - Labels now load instantly from local cache, then sync in background
- Share Extension loads much faster - Optimized label management to prevent crashes and lag
- Better performance when working with many labels - Share Extension now loads labels without delay
- Improved overall app stability - Reduced memory usage when working with large label collections
- Better offline support - labels always available even without internet
### Fixes & Improvements ### Fixes & Improvements
- Better color consistency throughout the app - Centralized color management for consistent appearance
- Improved text selection in articles - Improved annotation creation workflow
- Better formatted release notes - Better text selection handling in article view
- Various bug fixes and stability improvements - 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
--- ---

View File

@ -64,15 +64,6 @@ struct AnnotationsListView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .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: case .error:
@ -83,10 +74,8 @@ struct AnnotationsListView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button("Done") {
dismiss() dismiss()
} label: {
Image(systemName: "xmark")
} }
} }
} }

View File

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

View File

@ -101,16 +101,6 @@ struct BookmarkDetailLegacyView: View {
endSelector: endSelector 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) .frame(height: webViewHeight)
@ -126,7 +116,7 @@ struct BookmarkDetailLegacyView: View {
}) { }) {
HStack { HStack {
Image(systemName: "safari") Image(systemName: "safari")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
} }
.font(.title3.bold()) .font(.title3.bold())
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -402,7 +392,7 @@ struct BookmarkDetailLegacyView: View {
}) { }) {
HStack { HStack {
Image(systemName: "safari") Image(systemName: "safari")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
} }
.font(.title3.bold()) .font(.title3.bold())
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -456,7 +446,7 @@ struct BookmarkDetailLegacyView: View {
Button(action: { Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) { }) {
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }

View File

@ -411,7 +411,7 @@ struct BookmarkDetailView2: View {
Button(action: { Button(action: {
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener) URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
}) { }) {
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@ -475,16 +475,6 @@ struct BookmarkDetailView2: View {
endSelector: endSelector 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) .frame(height: webViewHeight)
@ -501,7 +491,7 @@ struct BookmarkDetailView2: View {
}) { }) {
HStack { HStack {
Image(systemName: "safari") Image(systemName: "safari")
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url)) Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
} }
.font(.title3.bold()) .font(.title3.bold())
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)

View File

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

View File

@ -13,7 +13,6 @@ struct NativeWebView: View {
var onScroll: ((Double) -> Void)? = nil var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String? var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
var onScrollToPosition: ((CGFloat) -> Void)? = nil
@State private var webPage = WebPage() @State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@ -24,7 +23,6 @@ struct NativeWebView: View {
.onAppear { .onAppear {
loadStyledContent() loadStyledContent()
setupAnnotationMessageHandler() setupAnnotationMessageHandler()
setupScrollToPositionHandler()
} }
.onChange(of: htmlContent) { _, _ in .onChange(of: htmlContent) { _, _ in
loadStyledContent() loadStyledContent()
@ -85,38 +83,6 @@ 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 { private func updateContentHeightWithJS() async {
var lastHeight: CGFloat = 0 var lastHeight: CGFloat = 0
@ -661,15 +627,8 @@ struct NativeWebView: View {
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]'); const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) { if (selectedElement) {
selectedElement.classList.add('selected'); 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(() => { setTimeout(() => {
window.__pendingScrollPosition = elementTop; selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100); }, 100);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,116 +3,58 @@ import SwiftUI
struct AppearanceSettingsView: View { struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine @State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system @State private var selectedTheme: Theme = .system
@State private var fontViewModel: FontSettingsViewModel
@State private var generalViewModel: SettingsGeneralViewModel
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
private let settingsRepository: PSettingsRepository private let settingsRepository: PSettingsRepository
init( init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
factory: UseCaseFactory = DefaultUseCaseFactory.shared,
fontViewModel: FontSettingsViewModel = FontSettingsViewModel(),
generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()
) {
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase() self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase() self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
self.settingsRepository = SettingsRepository() self.settingsRepository = SettingsRepository()
self.fontViewModel = fontViewModel
self.generalViewModel = generalViewModel
} }
var body: some View { var body: some View {
Group { VStack(spacing: 20) {
Section { SectionHeader(title: "Appearance".localized, icon: "paintbrush")
// Font Family .padding(.bottom, 4)
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 // Theme Section
Picker("Font size", selection: $fontViewModel.selectedFontSize) { VStack(alignment: .leading, spacing: 12) {
ForEach(FontSize.allCases, id: \.self) { size in Text("Theme")
Text(size.displayName).tag(size) .font(.headline)
}
}
.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) { Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme) Text(theme.displayName).tag(theme)
} }
} }
.pickerStyle(.segmented)
.onChange(of: selectedTheme) { .onChange(of: selectedTheme) {
saveThemeSettings() saveThemeSettings()
} }
}
// Card Layout als NavigationLink Divider()
NavigationLink {
CardLayoutSelectionView(
selectedCardLayout: $selectedCardLayout,
onSave: saveCardLayoutSettings
)
} label: {
HStack {
Text("Card Layout")
Spacer()
Text(selectedCardLayout.displayName)
.foregroundColor(.secondary)
}
}
// Open external links in // Card Layout Section
Picker("Open links in", selection: $generalViewModel.urlOpener) { VStack(alignment: .leading, spacing: 12) {
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in Text("Card Layout")
Text(urlOpener.displayName).tag(urlOpener) .font(.headline)
VStack(spacing: 16) {
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
CardLayoutPreview(
layout: layout,
isSelected: selectedCardLayout == layout
) {
selectedCardLayout = layout
saveCardLayoutSettings()
}
} }
} }
.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.")
} }
} }
.task { .onAppear {
await fontViewModel.loadFontSettings()
await generalViewModel.loadGeneralSettings()
loadSettings() loadSettings()
} }
} }
@ -154,11 +96,139 @@ struct AppearanceSettingsView: View {
} }
} }
#Preview {
NavigationStack { struct CardLayoutPreview: View {
List { let layout: CardLayoutStyle
AppearanceSettingsView() 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)
)
} }
.listStyle(.insetGrouped) .buttonStyle(.plain)
} }
} }
#Preview {
AppearanceSettingsView()
.cardStyle()
.padding()
}

View File

@ -8,65 +8,77 @@ struct CacheSettingsView: View {
@State private var showClearAlert: Bool = false @State private var showClearAlert: Bool = false
var body: some View { var body: some View {
Section { VStack(spacing: 20) {
HStack { SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
VStack(alignment: .leading, spacing: 2) { .padding(.bottom, 4)
Text("Current Cache Size")
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Refresh") {
updateCacheSize()
}
.font(.caption)
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 8) { VStack(spacing: 12) {
HStack { 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) { VStack(alignment: .leading, spacing: 2) {
Text("Clear Cache") Text("Current Cache Size")
.foregroundColor(isClearing ? .secondary : .red) .foregroundColor(.primary)
Text("Remove all cached images") Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Refresh") {
updateCacheSize()
}
.font(.caption)
.foregroundColor(.blue)
}
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Max Cache Size")
.foregroundColor(.primary)
Spacer()
Text("\(Int(maxCacheSize)) MB")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
Text("Max Cache Size")
}
.onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue)
}
.accentColor(.blue)
} }
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)
} }
.disabled(isClearing)
} header: {
Text("Cache Settings")
} }
.onAppear { .onAppear {
updateCacheSize() updateCacheSize()
@ -130,8 +142,7 @@ struct CacheSettingsView: View {
} }
#Preview { #Preview {
List { CacheSettingsView()
CacheSettingsView() .cardStyle()
} .padding()
.listStyle(.insetGrouped)
} }

View File

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

View File

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

View File

@ -15,54 +15,76 @@ struct FontSettingsView: View {
} }
var body: some View { var body: some View {
Group { VStack(spacing: 20) {
Section { SectionHeader(title: "Font Settings".localized, icon: "textformat")
.padding(.bottom, 4)
// Font Family Picker
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text("Font family")
.font(.headline)
Picker("Font family", selection: $viewModel.selectedFontFamily) { Picker("Font family", selection: $viewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family) Text(family.displayName).tag(family)
} }
} }
.pickerStyle(MenuPickerStyle())
.onChange(of: viewModel.selectedFontFamily) { .onChange(of: viewModel.selectedFontFamily) {
Task { Task {
await viewModel.saveFontSettings() 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")
} }
Section { VStack(spacing: 16) {
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 Size Picker
.font(viewModel.previewBodyFont) VStack(alignment: .leading, spacing: 8) {
.lineLimit(3) Text("Font size")
.font(.headline)
Text("12 min • Today • example.com") Picker("Font size", selection: $viewModel.selectedFontSize) {
.font(viewModel.previewCaptionFont) ForEach(FontSize.allCases, id: \.self) { size in
.foregroundColor(.secondary) 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)
.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 { .task {
await viewModel.loadFontSettings() await viewModel.loadFontSettings()
} }
@ -70,10 +92,7 @@ struct FontSettingsView: View {
} }
#Preview { #Preview {
List { FontSettingsView(viewModel: .init(
FontSettingsView(viewModel: .init( factory: MockUseCaseFactory())
factory: MockUseCaseFactory()) )
)
}
.listStyle(.insetGrouped)
} }

View File

@ -5,65 +5,108 @@ struct LegalPrivacySettingsView: View {
@State private var showingLegalNotice = false @State private var showingLegalNotice = false
var body: some View { var body: some View {
Group { VStack(spacing: 20) {
Section { SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
.padding(.bottom, 4)
VStack(spacing: 16) {
// Privacy Policy
Button(action: { Button(action: {
showingPrivacyPolicy = true showingPrivacyPolicy = true
}) { }) {
HStack { HStack {
Text(NSLocalizedString("Privacy Policy", comment: "")) Text(NSLocalizedString("Privacy Policy", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain)
// Legal Notice
Button(action: { Button(action: {
showingLegalNotice = true showingLegalNotice = true
}) { }) {
HStack { HStack {
Text(NSLocalizedString("Legal Notice", comment: "")) Text(NSLocalizedString("Legal Notice", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
} header: { .buttonStyle(.plain)
Text("Legal & Privacy")
}
Section { Divider()
Button(action: { .padding(.vertical, 8)
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text(NSLocalizedString("Report an Issue", comment: ""))
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Button(action: { // Support Section
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") { VStack(spacing: 12) {
UIApplication.shared.open(url) // Report an Issue
Button(action: {
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text(NSLocalizedString("Report an Issue", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
}) { .buttonStyle(.plain)
HStack {
Text(NSLocalizedString("Contact Support", comment: "")) // Contact Support
Spacer() Button(action: {
Image(systemName: "arrow.up.right") if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
.font(.caption) UIApplication.shared.open(url)
.foregroundColor(.secondary) }
}) {
HStack {
Text(NSLocalizedString("Contact Support", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain)
} }
} header: {
Text("Support")
} }
} }
.sheet(isPresented: $showingPrivacyPolicy) { .sheet(isPresented: $showingPrivacyPolicy) {
@ -76,8 +119,7 @@ struct LegalPrivacySettingsView: View {
} }
#Preview { #Preview {
List { LegalPrivacySettingsView()
LegalPrivacySettingsView() .cardStyle()
} .padding()
.listStyle(.insetGrouped)
} }

View File

@ -5,6 +5,8 @@
// Created by Ilyas Hallak on 16.08.25. // Created by Ilyas Hallak on 16.08.25.
// //
import SwiftUI import SwiftUI
import os import os
@ -13,70 +15,82 @@ struct LoggingConfigurationView: View {
private let logger = Logger.ui private let logger = Logger.ui
var body: some View { var body: some View {
List { NavigationView {
Section { Form {
Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled) Section(header: Text("Global Settings")) {
.tint(.green) VStack(alignment: .leading, spacing: 8) {
} header: { Text("Global Minimum Level")
Text("Logging Status") .font(.headline)
} footer: {
Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.")
}
if logConfig.isLoggingEnabled { Picker("Global Level", selection: $logConfig.globalMinLevel) {
Section { ForEach(LogLevel.allCases, id: \.self) { level in
NavigationLink { HStack {
GlobalLogLevelView(logConfig: logConfig) Text(level.emoji)
} label: { Text(level.rawValue == 0 ? "Debug" :
HStack { level.rawValue == 1 ? "Info" :
Label("Global Log Level", systemImage: "slider.horizontal.3") level.rawValue == 2 ? "Notice" :
Spacer() level.rawValue == 3 ? "Warning" :
Text(levelName(for: logConfig.globalMinLevel)) level.rawValue == 4 ? "Error" : "Critical")
.foregroundColor(.secondary) }
.tag(level)
}
} }
.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 Performance Logs", isOn: $logConfig.showPerformanceLogs)
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps) Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation) Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
} header: {
Text("Global Settings")
} footer: {
Text("Logs below the global level will be filtered out globally")
} }
}
if logConfig.isLoggingEnabled { Section(header: Text("Category-specific Levels")) {
Section {
ForEach(LogCategory.allCases, id: \.self) { category in ForEach(LogCategory.allCases, id: \.self) { category in
NavigationLink { VStack(alignment: .leading, spacing: 8) {
CategoryLogLevelView(category: category, logConfig: logConfig)
} label: {
HStack { HStack {
Text(category.rawValue) Text(category.rawValue)
.font(.headline)
Spacer() Spacer()
Text(levelName(for: logConfig.getLevel(for: category))) Text(levelName(for: logConfig.getLevel(for: category)))
.font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
}
}
} header: {
Text("Category-specific Levels")
} footer: {
Text("Configure log levels for each category individually")
}
}
Section { Picker("Level for \(category.rawValue)", selection: Binding(
Button(role: .destructive) { get: { logConfig.getLevel(for: category) },
resetToDefaults() set: { logConfig.setLevel($0, for: category) }
} label: { )) {
Label("Reset to Defaults", systemImage: "arrow.counterclockwise") 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()
} }
} }
.navigationTitle("Logging Configuration")
.navigationBarTitleDisplayMode(.inline)
} }
.navigationTitle("Logging Configuration")
.navigationBarTitleDisplayMode(.inline)
.onAppear { .onAppear {
logger.debug("Opened logging configuration view") logger.debug("Opened logging configuration view")
} }
@ -97,10 +111,12 @@ struct LoggingConfigurationView: View {
private func resetToDefaults() { private func resetToDefaults() {
logger.info("Resetting logging configuration to defaults") logger.info("Resetting logging configuration to defaults")
// Reset all category levels (this will use globalMinLevel as fallback)
for category in LogCategory.allCases { for category in LogCategory.allCases {
logConfig.setLevel(.debug, for: category) logConfig.setLevel(.debug, for: category)
} }
// Reset global settings
logConfig.globalMinLevel = .debug logConfig.globalMinLevel = .debug
logConfig.showPerformanceLogs = true logConfig.showPerformanceLogs = true
logConfig.showTimestamps = true logConfig.showTimestamps = true
@ -110,123 +126,6 @@ struct LoggingConfigurationView: View {
} }
} }
// 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 { #Preview {
NavigationStack { LoggingConfigurationView()
LoggingConfigurationView()
}
} }

View File

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

View File

@ -1,14 +1,56 @@
import SwiftUI 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 { struct ReleaseNotesView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationView { NavigationView {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 16) {
if let markdownContent = loadReleaseNotes() { if let attributedString = loadReleaseNotes() {
MarkdownContentView(content: markdownContent) Text(attributedString)
.textSelection(.enabled)
.padding() .padding()
} else { } else {
Text("Unable to load release notes") Text("Unable to load release notes")
@ -29,12 +71,13 @@ struct ReleaseNotesView: View {
} }
} }
private func loadReleaseNotes() -> String? { private func loadReleaseNotes() -> AttributedString? {
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"), guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
let markdownContent = try? String(contentsOf: url) else { let markdownContent = try? String(contentsOf: url),
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
return nil return nil
} }
return markdownContent return attributedString
} }
} }

View File

@ -16,82 +16,103 @@ struct SettingsContainerView: View {
} }
var body: some View { var body: some View {
List { ScrollView {
AppearanceSettingsView() LazyVStack(spacing: 20) {
FontSettingsView()
.cardStyle()
CacheSettingsView() AppearanceSettingsView()
.cardStyle()
SettingsGeneralView() CacheSettingsView()
.cardStyle()
SettingsServerView() SettingsGeneralView()
.cardStyle()
LegalPrivacySettingsView() SettingsServerView()
.cardStyle()
// Debug-only Logging Configuration LegalPrivacySettingsView()
#if DEBUG .cardStyle()
if Bundle.main.isDebugBuild {
debugSettingsSection // Debug-only Logging Configuration
if Bundle.main.isDebugBuild {
debugSettingsSection
}
} }
#endif .padding()
.background(Color(.systemGroupedBackground))
// App Info Section AppInfo()
appInfoSection
Spacer()
} }
.listStyle(.insetGrouped) .background(Color(.systemGroupedBackground))
.navigationTitle("Settings") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} }
@ViewBuilder @ViewBuilder
private var debugSettingsSection: some View { private var debugSettingsSection: some View {
Section { VStack(alignment: .leading, spacing: 16) {
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 { HStack {
Image(systemName: "ant.fill")
.foregroundColor(.orange)
Text("Debug Settings") Text("Debug Settings")
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Text("DEBUG BUILD") Text("DEBUG BUILD")
.font(.caption2) .font(.caption)
.padding(.horizontal, 6) .padding(.horizontal, 8)
.padding(.vertical, 2) .padding(.vertical, 4)
.background(Color.orange.opacity(0.2)) .background(Color.orange.opacity(0.2))
.foregroundColor(.orange) .foregroundColor(.orange)
.clipShape(Capsule()) .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)
.foregroundColor(.secondary)
}
}
} }
.cardStyle()
} }
@ViewBuilder @ViewBuilder
private var appInfoSection: some View { func AppInfo() -> some View {
Section { VStack(spacing: 4) {
VStack(spacing: 8) { HStack(spacing: 8) {
HStack(spacing: 8) { Image(systemName: "info.circle")
Image(systemName: "info.circle") .foregroundColor(.secondary)
.foregroundColor(.secondary) Text("Version \(appVersion)")
Text("Version \(appVersion)") .font(.footnote)
.font(.footnote) .foregroundColor(.secondary)
.foregroundColor(.secondary) }
} HStack(spacing: 8) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
Text("Developer:") Text("Developer:")
.font(.footnote) .font(.footnote)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -102,23 +123,26 @@ struct SettingsContainerView: View {
} }
.font(.footnote) .font(.footnote)
.foregroundColor(.blue) .foregroundColor(.blue)
} .underline()
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundColor(.secondary)
Text("From Bremen with 💚")
.font(.footnote)
.foregroundColor(.secondary)
} }
} }
.frame(maxWidth: .infinity) HStack(spacing: 8) {
.padding(.vertical, 8) Image(systemName: "globe")
.foregroundColor(.secondary)
Text("From Bremen with 💚")
.font(.footnote)
.foregroundColor(.secondary)
}
} }
.frame(maxWidth: .infinity)
.padding(.top, 16)
.padding(.bottom, 4)
.multilineTextAlignment(.center)
.opacity(0.7)
} }
} }
// Card Modifier für einheitlichen Look (kept for backwards compatibility with other views) // Card Modifier für einheitlichen Look
extension View { extension View {
func cardStyle() -> some View { func cardStyle() -> some View {
self self
@ -130,7 +154,5 @@ extension View {
} }
#Preview { #Preview {
NavigationStack { SettingsContainerView()
SettingsContainerView()
}
} }

View File

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

View File

@ -11,33 +11,189 @@ struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel() @State private var viewModel = SettingsServerViewModel()
@State private var showingLogoutAlert = false @State private var showingLogoutAlert = false
init(showingLogoutAlert: Bool = false) {
self.showingLogoutAlert = showingLogoutAlert
}
var body: some View { var body: some View {
Section { VStack(spacing: 20) {
SettingsRowValue( SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
icon: "server.rack", .padding(.bottom, 4)
title: "Server",
value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint
)
SettingsRowValue( Text(viewModel.isSetupMode ?
icon: "person.circle.fill", "Enter your Readeck server details to get started." :
title: "Username", "Your current server connection and login credentials.")
value: viewModel.username.isEmpty ? "Not set" : viewModel.username .font(.body)
) .foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
SettingsRowButton( // Form
icon: "rectangle.portrait.and.arrow.right", VStack(spacing: 16) {
iconColor: .red, // Server Endpoint
title: "Logout", VStack(alignment: .leading, spacing: 8) {
subtitle: nil, if viewModel.isSetupMode {
destructive: true TextField("",
) { text: $viewModel.endpoint,
showingLogoutAlert = true 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)
}
} }
} header: {
Text("Server Connection")
} footer: {
Text("Your current server connection and login credentials.")
} }
.alert("Logout", isPresented: $showingLogoutAlert) { .alert("Logout", isPresented: $showingLogoutAlert) {
Button("Cancel", role: .cancel) { } Button("Cancel", role: .cancel) { }
@ -55,9 +211,22 @@ struct SettingsServerView: View {
} }
} }
#Preview { // MARK: - Quick Input Chip Component
List {
SettingsServerView() 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)
}
} }
.listStyle(.insetGrouped)
} }

View File

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

View File

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

View File

@ -1,145 +0,0 @@
//
// 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
}()
}