Compare commits
10 Commits
202eba48f3
...
af93c0e79a
| Author | SHA1 | Date | |
|---|---|---|---|
| af93c0e79a | |||
| a3825271f4 | |||
| 6545e4c57b | |||
| 8bd39295c6 | |||
| 5f44c8631b | |||
| c2d7989be9 | |||
| 9e0ebbe0d7 | |||
| 7c5f68e174 | |||
| 0ce3e45d7b | |||
| 7cac529c7e |
@ -9,6 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* 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 */
|
||||||
@ -86,7 +87,6 @@
|
|||||||
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,6 +94,8 @@
|
|||||||
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 */;
|
||||||
};
|
};
|
||||||
@ -151,6 +153,7 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@ -242,6 +245,7 @@
|
|||||||
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 */;
|
||||||
@ -333,6 +337,7 @@
|
|||||||
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 */;
|
||||||
@ -437,7 +442,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 = 31;
|
CURRENT_PROJECT_VERSION = 33;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -470,7 +475,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 = 31;
|
CURRENT_PROJECT_VERSION = 33;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -625,7 +630,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 = 31;
|
CURRENT_PROJECT_VERSION = 33;
|
||||||
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;
|
||||||
@ -669,7 +674,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 = 31;
|
CURRENT_PROJECT_VERSION = 33;
|
||||||
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;
|
||||||
@ -854,6 +859,14 @@
|
|||||||
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";
|
||||||
@ -878,6 +891,11 @@
|
|||||||
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" */;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457",
|
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "kingfisher",
|
"identity" : "kingfisher",
|
||||||
@ -19,6 +19,15 @@
|
|||||||
"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",
|
||||||
@ -37,6 +46,24 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -20,6 +20,7 @@ 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 {
|
||||||
@ -486,6 +487,44 @@ 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 {
|
||||||
|
|||||||
@ -21,4 +21,8 @@ class AnnotationsRepository: PAnnotationsRepository {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
||||||
|
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
protocol PAnnotationsRepository {
|
protocol PAnnotationsRepository {
|
||||||
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
||||||
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
17
readeck/Domain/UseCase/DeleteAnnotationUseCase.swift
Normal file
17
readeck/Domain/UseCase/DeleteAnnotationUseCase.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PDeleteAnnotationUseCase {
|
||||||
|
func execute(bookmarkId: String, annotationId: String) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteAnnotationUseCase: PDeleteAnnotationUseCase {
|
||||||
|
private let repository: PAnnotationsRepository
|
||||||
|
|
||||||
|
init(repository: PAnnotationsRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkId: String, annotationId: String) async throws {
|
||||||
|
try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,11 +40,11 @@
|
|||||||
"Tags" = "Labels";
|
"Tags" = "Labels";
|
||||||
|
|
||||||
/* Settings Sections */
|
/* Settings Sections */
|
||||||
"Font Settings" = "Schriftart-Einstellungen";
|
"Font Settings" = "Schriftart";
|
||||||
"Appearance" = "Darstellung";
|
"Appearance" = "Darstellung";
|
||||||
"Cache Settings" = "Cache-Einstellungen";
|
"Cache Settings" = "Cache";
|
||||||
"General Settings" = "Allgemeine Einstellungen";
|
"General Settings" = "Allgemein";
|
||||||
"Server Settings" = "Server-Einstellungen";
|
"Server Settings" = "Server";
|
||||||
"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-Einstellungen";
|
"Debug Settings" = "Debug";
|
||||||
"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-Einstellungen";
|
"Font Settings" = "Schrift";
|
||||||
"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" = "Globale Einstellungen";
|
"Global Settings" = "Global";
|
||||||
"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,6 +105,8 @@
|
|||||||
"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.";
|
||||||
|
|||||||
@ -101,6 +101,8 @@
|
|||||||
"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.";
|
||||||
|
|||||||
@ -64,6 +64,15 @@ 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:
|
||||||
@ -74,8 +83,10 @@ struct AnnotationsListView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Done") {
|
Button {
|
||||||
dismiss()
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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
|
||||||
@ -11,6 +12,7 @@ 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
|
||||||
@ -26,4 +28,15 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,6 +101,16 @@ 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)
|
||||||
@ -116,7 +126,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -392,7 +402,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -446,7 +456,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.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@ -475,6 +475,16 @@ 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)
|
||||||
@ -491,7 +501,7 @@ struct BookmarkDetailView2: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@ -254,7 +254,7 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
Label(URLUtil.openUrlLabel(for: bookmark.url), 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.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ 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
|
||||||
@ -23,6 +24,7 @@ struct NativeWebView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
setupAnnotationMessageHandler()
|
setupAnnotationMessageHandler()
|
||||||
|
setupScrollToPositionHandler()
|
||||||
}
|
}
|
||||||
.onChange(of: htmlContent) { _, _ in
|
.onChange(of: htmlContent) { _, _ in
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
@ -82,6 +84,38 @@ struct NativeWebView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupScrollToPositionHandler() {
|
||||||
|
guard let onScrollToPosition = onScrollToPosition else { return }
|
||||||
|
|
||||||
|
// Poll for scroll position messages from JavaScript
|
||||||
|
Task { @MainActor in
|
||||||
|
let page = webPage
|
||||||
|
|
||||||
|
while true {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
|
||||||
|
|
||||||
|
let script = """
|
||||||
|
return (function() {
|
||||||
|
if (window.__pendingScrollPosition !== undefined) {
|
||||||
|
const position = window.__pendingScrollPosition;
|
||||||
|
window.__pendingScrollPosition = undefined;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let position = try await page.callJavaScript(script) as? Double {
|
||||||
|
onScrollToPosition(CGFloat(position))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateContentHeightWithJS() async {
|
private func updateContentHeightWithJS() async {
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
@ -627,8 +661,15 @@ 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(() => {
|
||||||
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
window.__pendingScrollPosition = elementTop;
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
308
readeck/UI/Components/SettingsRow.swift
Normal file
308
readeck/UI/Components/SettingsRow.swift
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
//
|
||||||
|
// SettingsRow.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 31.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Settings Row with Navigation Link
|
||||||
|
struct SettingsRowNavigationLink<Destination: View>: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let destination: Destination
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
@ViewBuilder destination: () -> Destination
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.destination = destination()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink(destination: destination) {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row with Toggle
|
||||||
|
struct SettingsRowToggle: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
@Binding var isOn: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
isOn: Binding<Bool>
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self._isOn = isOn
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle
|
||||||
|
)
|
||||||
|
Toggle("", isOn: $isOn)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row with Value Display
|
||||||
|
struct SettingsRowValue: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let valueColor: Color
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
valueColor: Color = .secondary
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.value = value
|
||||||
|
self.valueColor = valueColor
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: nil
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.foregroundColor(valueColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row Button (for actions)
|
||||||
|
struct SettingsRowButton: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let destructive: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
destructive: Bool = false,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.destructive = destructive
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: destructive ? .red : iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
titleColor: destructive ? .red : .primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row with Picker
|
||||||
|
struct SettingsRowPicker<T: Hashable>: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let selection: Binding<T>
|
||||||
|
let options: [(value: T, label: String)]
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .accentColor,
|
||||||
|
title: String,
|
||||||
|
selection: Binding<T>,
|
||||||
|
options: [(value: T, label: String)]
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.selection = selection
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
SettingsRowLabel(
|
||||||
|
icon: icon,
|
||||||
|
iconColor: iconColor,
|
||||||
|
title: title,
|
||||||
|
subtitle: nil
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: selection) {
|
||||||
|
ForEach(options, id: \.value) { option in
|
||||||
|
Text(option.label).tag(option.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Row Label (internal component)
|
||||||
|
struct SettingsRowLabel: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let titleColor: Color
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String?,
|
||||||
|
iconColor: Color,
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
titleColor: Color = .primary
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.titleColor = titleColor
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundColor(iconColor)
|
||||||
|
.frame(width: 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.foregroundColor(titleColor)
|
||||||
|
|
||||||
|
if let subtitle = subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
#Preview("Navigation Link") {
|
||||||
|
List {
|
||||||
|
SettingsRowNavigationLink(
|
||||||
|
icon: "paintbrush",
|
||||||
|
title: "App Icon",
|
||||||
|
subtitle: nil
|
||||||
|
) {
|
||||||
|
Text("Detail View")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Toggle") {
|
||||||
|
List {
|
||||||
|
SettingsRowToggle(
|
||||||
|
icon: "speaker.wave.2",
|
||||||
|
title: "Read Aloud Feature",
|
||||||
|
subtitle: "Text-to-Speech functionality",
|
||||||
|
isOn: .constant(true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Value Display") {
|
||||||
|
List {
|
||||||
|
SettingsRowValue(
|
||||||
|
icon: "paintbrush.fill",
|
||||||
|
iconColor: .purple,
|
||||||
|
title: "Tint Color",
|
||||||
|
value: "Purple"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Button") {
|
||||||
|
List {
|
||||||
|
SettingsRowButton(
|
||||||
|
icon: "trash",
|
||||||
|
iconColor: .red,
|
||||||
|
title: "Clear Cache",
|
||||||
|
subtitle: "Remove all cached images",
|
||||||
|
destructive: true
|
||||||
|
) {
|
||||||
|
print("Clear cache tapped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Picker") {
|
||||||
|
List {
|
||||||
|
SettingsRowPicker(
|
||||||
|
icon: "textformat",
|
||||||
|
title: "Font Family",
|
||||||
|
selection: .constant("System"),
|
||||||
|
options: [
|
||||||
|
("System", "System"),
|
||||||
|
("Serif", "Serif"),
|
||||||
|
("Monospace", "Monospace")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
var onScroll: ((Double) -> Void)? = nil
|
var 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 {
|
||||||
@ -31,9 +32,11 @@ 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
|
||||||
@ -43,6 +46,7 @@ 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)
|
||||||
@ -332,6 +336,7 @@ 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()
|
||||||
}
|
}
|
||||||
@ -379,8 +384,15 @@ 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(() => {
|
||||||
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -647,6 +659,7 @@ 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?
|
||||||
@ -702,6 +715,11 @@ 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) {
|
||||||
@ -778,5 +796,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
onHeightChange = nil
|
onHeightChange = nil
|
||||||
onScroll = nil
|
onScroll = nil
|
||||||
onAnnotationCreated = nil
|
onAnnotationCreated = nil
|
||||||
|
onScrollToPosition = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ protocol UseCaseFactory {
|
|||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||||
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
||||||
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -125,4 +126,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
|
||||||
|
return DeleteAnnotationUseCase(repository: annotationsRepository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,6 +92,10 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
MockGetBookmarkAnnotationsUseCase()
|
MockGetBookmarkAnnotationsUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
|
||||||
|
MockDeleteAnnotationUseCase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -250,6 +254,12 @@ class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase {
|
||||||
|
func execute(bookmarkId: String, annotationId: String) async throws {
|
||||||
|
// Mock implementation - do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Bookmark {
|
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)
|
||||||
|
|||||||
175
readeck/UI/Onboarding/OnboardingServerView.swift
Normal file
175
readeck/UI/Onboarding/OnboardingServerView.swift
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
//
|
||||||
|
// OnboardingServerView.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 31.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OnboardingServerView: View {
|
||||||
|
@State private var viewModel = SettingsServerViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
SectionHeader(title: "Server Settings".localized, icon: "server.rack")
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
Text("Enter your Readeck server details to get started.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
// Form
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Server Endpoint
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
TextField("",
|
||||||
|
text: $viewModel.endpoint,
|
||||||
|
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.onChange(of: viewModel.endpoint) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Input Chips
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
QuickInputChip(text: "http://", action: {
|
||||||
|
if !viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint = "http://" + viewModel.endpoint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: "https://", action: {
|
||||||
|
if !viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint = "https://" + viewModel.endpoint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: "192.168.", action: {
|
||||||
|
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
|
||||||
|
if viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint += "192.168."
|
||||||
|
} else {
|
||||||
|
viewModel.endpoint = "http://192.168."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: ":8000", action: {
|
||||||
|
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
|
||||||
|
viewModel.endpoint += ":8000"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
TextField("",
|
||||||
|
text: $viewModel.username,
|
||||||
|
prompt: Text("Username").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.onChange(of: viewModel.username) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
SecureField("",
|
||||||
|
text: $viewModel.password,
|
||||||
|
prompt: Text("Password").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onChange(of: viewModel.password) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text(successMessage)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button(action: {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveServerSettings()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
}
|
||||||
|
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadServerSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Input Chip Component
|
||||||
|
|
||||||
|
struct QuickInputChip: View {
|
||||||
|
let text: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.systemGray5))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingServerView()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@ -18,22 +18,17 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi
|
|||||||
### Performance Improvements
|
### Performance Improvements
|
||||||
|
|
||||||
- **Dramatically faster label loading** - especially with 1000+ labels
|
- **Dramatically faster label loading** - especially with 1000+ labels
|
||||||
- Labels now load instantly from local cache, then sync in background
|
- Labels now load instantly, even without internet connection
|
||||||
- Optimized label management to prevent crashes and lag
|
- Share Extension loads much faster
|
||||||
- Share Extension now loads labels without delay
|
- Better performance when working with many labels
|
||||||
- Reduced memory usage when working with large label collections
|
- Improved overall app stability
|
||||||
- Better offline support - labels always available even without internet
|
|
||||||
|
|
||||||
### Fixes & Improvements
|
### Fixes & Improvements
|
||||||
|
|
||||||
- Centralized color management for consistent appearance
|
- Better color consistency throughout the app
|
||||||
- Improved annotation creation workflow
|
- Improved text selection in articles
|
||||||
- Better text selection handling in article view
|
- Better formatted release notes
|
||||||
- Implemented lazy loading for label lists
|
- Various bug fixes and stability improvements
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -3,62 +3,120 @@ 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(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
init(
|
||||||
|
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 {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Appearance".localized, icon: "paintbrush")
|
Section {
|
||||||
.padding(.bottom, 4)
|
// Font Family
|
||||||
|
Picker("Font family", selection: $fontViewModel.selectedFontFamily) {
|
||||||
// Theme Section
|
ForEach(FontFamily.allCases, id: \.self) { family in
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
Text(family.displayName).tag(family)
|
||||||
Text("Theme")
|
}
|
||||||
.font(.headline)
|
}
|
||||||
|
.onChange(of: fontViewModel.selectedFontFamily) {
|
||||||
|
Task {
|
||||||
|
await fontViewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font Size
|
||||||
|
Picker("Font size", selection: $fontViewModel.selectedFontSize) {
|
||||||
|
ForEach(FontSize.allCases, id: \.self) { size in
|
||||||
|
Text(size.displayName).tag(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: fontViewModel.selectedFontSize) {
|
||||||
|
Task {
|
||||||
|
await fontViewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font Preview - direkt in der gleichen Section
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("readeck Bookmark Title")
|
||||||
|
.font(fontViewModel.previewTitleFont)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
||||||
|
.font(fontViewModel.previewBodyFont)
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
Text("12 min • Today • example.com")
|
||||||
|
.font(fontViewModel.previewCaptionFont)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.listRowBackground(Color(.systemGray6))
|
||||||
|
|
||||||
|
// Theme Picker (Menu statt Segmented)
|
||||||
Picker("Theme", selection: $selectedTheme) {
|
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(
|
||||||
// Card Layout Section
|
selectedCardLayout: $selectedCardLayout,
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
onSave: saveCardLayoutSettings
|
||||||
Text("Card Layout")
|
)
|
||||||
.font(.headline)
|
} label: {
|
||||||
|
HStack {
|
||||||
VStack(spacing: 16) {
|
Text("Card Layout")
|
||||||
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
Spacer()
|
||||||
CardLayoutPreview(
|
Text(selectedCardLayout.displayName)
|
||||||
layout: layout,
|
.foregroundColor(.secondary)
|
||||||
isSelected: selectedCardLayout == layout
|
|
||||||
) {
|
|
||||||
selectedCardLayout = layout
|
|
||||||
saveCardLayoutSettings()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open external links in
|
||||||
|
Picker("Open links in", selection: $generalViewModel.urlOpener) {
|
||||||
|
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
|
||||||
|
Text(urlOpener.displayName).tag(urlOpener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: generalViewModel.urlOpener) {
|
||||||
|
Task {
|
||||||
|
await generalViewModel.saveGeneralSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Appearance")
|
||||||
|
} footer: {
|
||||||
|
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.task {
|
||||||
|
await fontViewModel.loadFontSettings()
|
||||||
|
await generalViewModel.loadGeneralSettings()
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSettings() {
|
private func loadSettings() {
|
||||||
Task {
|
Task {
|
||||||
// Load both theme and card layout from repository
|
// Load both theme and card layout from repository
|
||||||
@ -70,21 +128,21 @@ struct AppearanceSettingsView: View {
|
|||||||
selectedCardLayout = await loadCardLayoutUseCase.execute()
|
selectedCardLayout = await loadCardLayoutUseCase.execute()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveThemeSettings() {
|
private func saveThemeSettings() {
|
||||||
Task {
|
Task {
|
||||||
// Load current settings, update theme, and save back
|
// Load current settings, update theme, and save back
|
||||||
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
||||||
settings.theme = selectedTheme
|
settings.theme = selectedTheme
|
||||||
try? await settingsRepository.saveSettings(settings)
|
try? await settingsRepository.saveSettings(settings)
|
||||||
|
|
||||||
// Notify app about theme change
|
// Notify app about theme change
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveCardLayoutSettings() {
|
private func saveCardLayoutSettings() {
|
||||||
Task {
|
Task {
|
||||||
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
||||||
@ -96,139 +154,11 @@ struct AppearanceSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
struct CardLayoutPreview: View {
|
NavigationStack {
|
||||||
let layout: CardLayoutStyle
|
List {
|
||||||
let isSelected: Bool
|
AppearanceSettingsView()
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
AppearanceSettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,79 +6,67 @@ struct CacheSettingsView: View {
|
|||||||
@State private var maxCacheSize: Double = 200
|
@State private var maxCacheSize: Double = 200
|
||||||
@State private var isClearing: Bool = false
|
@State private var isClearing: Bool = false
|
||||||
@State private var showClearAlert: Bool = false
|
@State private var showClearAlert: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Section {
|
||||||
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
|
HStack {
|
||||||
.padding(.bottom, 4)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Current Cache Size")
|
||||||
VStack(spacing: 12) {
|
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
||||||
HStack {
|
.font(.caption)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.foregroundColor(.secondary)
|
||||||
Text("Current Cache Size")
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button("Refresh") {
|
|
||||||
updateCacheSize()
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
Divider()
|
Button("Refresh") {
|
||||||
|
updateCacheSize()
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack {
|
|
||||||
Text("Max Cache Size")
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(maxCacheSize)) MB")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
|
||||||
Text("Max Cache Size")
|
|
||||||
}
|
|
||||||
.onChange(of: maxCacheSize) { _, newValue in
|
|
||||||
updateMaxCacheSize(newValue)
|
|
||||||
}
|
|
||||||
.accentColor(.blue)
|
|
||||||
}
|
}
|
||||||
|
.font(.caption)
|
||||||
Divider()
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(maxCacheSize)) MB")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
}
|
||||||
|
.onChange(of: maxCacheSize) { _, newValue in
|
||||||
|
updateMaxCacheSize(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showClearAlert = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if isClearing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Clear Cache")
|
||||||
|
.foregroundColor(isClearing ? .secondary : .red)
|
||||||
|
Text("Remove all cached images")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isClearing)
|
||||||
|
} header: {
|
||||||
|
Text("Cache Settings")
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateCacheSize()
|
updateCacheSize()
|
||||||
@ -93,7 +81,7 @@ struct CacheSettingsView: View {
|
|||||||
Text("This will remove all cached images. They will be downloaded again when needed.")
|
Text("This will remove all cached images. They will be downloaded again when needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCacheSize() {
|
private func updateCacheSize() {
|
||||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -107,7 +95,7 @@ struct CacheSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadMaxCacheSize() {
|
private func loadMaxCacheSize() {
|
||||||
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
||||||
if let savedSize = savedSize {
|
if let savedSize = savedSize {
|
||||||
@ -120,29 +108,30 @@ struct CacheSettingsView: View {
|
|||||||
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMaxCacheSize(_ newSize: Double) {
|
private func updateMaxCacheSize(_ newSize: Double) {
|
||||||
let bytes = UInt(newSize * 1024 * 1024)
|
let bytes = UInt(newSize * 1024 * 1024)
|
||||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
||||||
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearCache() {
|
private func clearCache() {
|
||||||
isClearing = true
|
isClearing = true
|
||||||
|
|
||||||
KingfisherManager.shared.cache.clearDiskCache {
|
KingfisherManager.shared.cache.clearDiskCache {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isClearing = false
|
self.isClearing = false
|
||||||
self.updateCacheSize()
|
self.updateCacheSize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KingfisherManager.shared.cache.clearMemoryCache()
|
KingfisherManager.shared.cache.clearMemoryCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CacheSettingsView()
|
List {
|
||||||
.cardStyle()
|
CacheSettingsView()
|
||||||
.padding()
|
}
|
||||||
}
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|||||||
171
readeck/UI/Settings/CardLayoutSelectionView.swift
Normal file
171
readeck/UI/Settings/CardLayoutSelectionView.swift
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
//
|
||||||
|
// CardLayoutSelectionView.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 31.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CardLayoutSelectionView: View {
|
||||||
|
@Binding var selectedCardLayout: CardLayoutStyle
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
||||||
|
CardLayoutPreview(
|
||||||
|
layout: layout,
|
||||||
|
isSelected: selectedCardLayout == layout
|
||||||
|
) {
|
||||||
|
selectedCardLayout = layout
|
||||||
|
onSave()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Card Layout")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CardLayoutPreview: View {
|
||||||
|
let layout: CardLayoutStyle
|
||||||
|
let isSelected: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Visual Preview
|
||||||
|
switch layout {
|
||||||
|
case .compact:
|
||||||
|
// Compact: Small image on left, content on right
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 60)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.4))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 50)
|
||||||
|
|
||||||
|
case .magazine:
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 5)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 40)
|
||||||
|
|
||||||
|
Text("Fixed 140px")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 65)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||||
|
|
||||||
|
case .natural:
|
||||||
|
VStack(spacing: 3) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(height: 38)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 5)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 35)
|
||||||
|
|
||||||
|
Text("Original ratio")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 75)
|
||||||
|
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(layout.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(layout.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
CardLayoutSelectionView(
|
||||||
|
selectedCardLayout: .constant(.magazine),
|
||||||
|
onSave: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
444
readeck/UI/Settings/DebugLogViewer.swift
Normal file
444
readeck/UI/Settings/DebugLogViewer.swift
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
//
|
||||||
|
// DebugLogViewer.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DebugLogViewer: View {
|
||||||
|
@State private var entries: [LogEntry] = []
|
||||||
|
@State private var selectedLevel: LogLevel?
|
||||||
|
@State private var selectedCategory: LogCategory?
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var showShareSheet = false
|
||||||
|
@State private var exportText = ""
|
||||||
|
@State private var autoScroll = true
|
||||||
|
@State private var showFilters = false
|
||||||
|
@StateObject private var logConfig = LogConfiguration.shared
|
||||||
|
|
||||||
|
private let logger = Logger.ui
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Logging Disabled Warning
|
||||||
|
if !logConfig.isLoggingEnabled {
|
||||||
|
loggingDisabledBanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Bar
|
||||||
|
if showFilters {
|
||||||
|
filterBar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log List
|
||||||
|
if filteredEntries.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
List {
|
||||||
|
ForEach(filteredEntries) { entry in
|
||||||
|
LogEntryRow(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.onChange(of: entries.count) { oldValue, newValue in
|
||||||
|
if autoScroll, let lastEntry = filteredEntries.last {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(lastEntry.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Debug Logs")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
showFilters.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
showFilters ? "Hide Filters" : "Show Filters",
|
||||||
|
systemImage: "line.3.horizontal.decrease.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
autoScroll.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll",
|
||||||
|
systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await refreshLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await exportLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Export Logs", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await clearLogs()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Clear All Logs", systemImage: "trash")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: "Search logs")
|
||||||
|
.task {
|
||||||
|
await refreshLogs()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showShareSheet) {
|
||||||
|
ActivityView(activityItems: [exportText])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var filterBar: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Filters")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Level Filter
|
||||||
|
Menu {
|
||||||
|
Button("All Levels") {
|
||||||
|
selectedLevel = nil
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
selectedLevel = level
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
if selectedLevel == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level")
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
||||||
|
.foregroundColor(selectedLevel != nil ? .accentColor : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category Filter
|
||||||
|
Menu {
|
||||||
|
Button("All Categories") {
|
||||||
|
selectedCategory = nil
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||||
|
Button {
|
||||||
|
selectedCategory = category
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(category.rawValue)
|
||||||
|
if selectedCategory == category {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
Text(selectedCategory?.rawValue ?? "Category")
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
||||||
|
.foregroundColor(selectedCategory != nil ? .accentColor : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Filters
|
||||||
|
if selectedLevel != nil || selectedCategory != nil {
|
||||||
|
Button {
|
||||||
|
selectedLevel = nil
|
||||||
|
selectedCategory = nil
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
Text("Clear")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.systemGray5))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loggingDisabledBanner: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.title3)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Logging Disabled")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text("Enable logging in settings to capture new logs")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
logConfig.isLoggingEnabled = true
|
||||||
|
} label: {
|
||||||
|
Text("Enable")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.orange)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "doc.text.magnifyingglass")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("No Logs Found")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil {
|
||||||
|
Text("Try adjusting your filters or search criteria")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
searchText = ""
|
||||||
|
selectedLevel = nil
|
||||||
|
selectedCategory = nil
|
||||||
|
} label: {
|
||||||
|
Text("Clear Filters")
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Logs will appear here as they are generated")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredEntries: [LogEntry] {
|
||||||
|
var filtered = entries
|
||||||
|
|
||||||
|
if let level = selectedLevel {
|
||||||
|
filtered = filtered.filter { $0.level == level }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let category = selectedCategory {
|
||||||
|
filtered = filtered.filter { $0.category == category }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
filtered = filtered.filter {
|
||||||
|
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshLogs() async {
|
||||||
|
entries = await LogStore.shared.getEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearLogs() async {
|
||||||
|
await LogStore.shared.clear()
|
||||||
|
await refreshLogs()
|
||||||
|
logger.info("Cleared all debug logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportLogs() async {
|
||||||
|
exportText = await LogStore.shared.exportAsText()
|
||||||
|
showShareSheet = true
|
||||||
|
logger.info("Exported debug logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log Entry Row
|
||||||
|
|
||||||
|
struct LogEntryRow: View {
|
||||||
|
let entry: LogEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
// Level Badge
|
||||||
|
Text(levelName(for: entry.level))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(levelColor(for: entry.level).opacity(0.2))
|
||||||
|
.foregroundColor(levelColor(for: entry.level))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
// Category
|
||||||
|
Text(entry.category.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
Text(entry.formattedTimestamp)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message
|
||||||
|
Text(entry.message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
// Source Location
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(entry.fileName):\(entry.line)")
|
||||||
|
.font(.caption)
|
||||||
|
Text("•")
|
||||||
|
.font(.caption)
|
||||||
|
Text(entry.function)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "DEBUG"
|
||||||
|
case 1: return "INFO"
|
||||||
|
case 2: return "NOTICE"
|
||||||
|
case 3: return "WARN"
|
||||||
|
case 4: return "ERROR"
|
||||||
|
case 5: return "CRITICAL"
|
||||||
|
default: return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelColor(for level: LogLevel) -> Color {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return .blue
|
||||||
|
case 1: return .green
|
||||||
|
case 2: return .cyan
|
||||||
|
case 3: return .orange
|
||||||
|
case 4: return .red
|
||||||
|
case 5: return .purple
|
||||||
|
default: return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Activity View (for Share Sheet)
|
||||||
|
|
||||||
|
struct ActivityView: UIViewControllerRepresentable {
|
||||||
|
let activityItems: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
DebugLogViewer()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,82 +9,60 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FontSettingsView: View {
|
struct FontSettingsView: View {
|
||||||
@State private var viewModel: FontSettingsViewModel
|
@State private var viewModel: FontSettingsViewModel
|
||||||
|
|
||||||
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
|
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Font Settings".localized, icon: "textformat")
|
Section {
|
||||||
.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")
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
// Font Size Picker
|
Text("readeck Bookmark Title")
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.font(viewModel.previewTitleFont)
|
||||||
Text("Font size")
|
.fontWeight(.semibold)
|
||||||
.font(.headline)
|
.lineLimit(1)
|
||||||
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
|
||||||
ForEach(FontSize.allCases, id: \.self) { size in
|
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
||||||
Text(size.displayName).tag(size)
|
.font(viewModel.previewBodyFont)
|
||||||
}
|
.lineLimit(3)
|
||||||
}
|
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
Text("12 min • Today • example.com")
|
||||||
.onChange(of: viewModel.selectedFontSize) {
|
.font(viewModel.previewCaptionFont)
|
||||||
Task {
|
|
||||||
await viewModel.saveFontSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font Preview
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Preview")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
.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()
|
||||||
}
|
}
|
||||||
@ -92,7 +70,10 @@ struct FontSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
FontSettingsView(viewModel: .init(
|
List {
|
||||||
factory: MockUseCaseFactory())
|
FontSettingsView(viewModel: .init(
|
||||||
)
|
factory: MockUseCaseFactory())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,110 +3,67 @@ import SwiftUI
|
|||||||
struct LegalPrivacySettingsView: View {
|
struct LegalPrivacySettingsView: View {
|
||||||
@State private var showingPrivacyPolicy = false
|
@State private var showingPrivacyPolicy = false
|
||||||
@State private var showingLegalNotice = false
|
@State private var showingLegalNotice = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
|
Section {
|
||||||
.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))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
} header: {
|
||||||
|
Text("Legal & Privacy")
|
||||||
Divider()
|
}
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
Section {
|
||||||
// Support Section
|
Button(action: {
|
||||||
VStack(spacing: 12) {
|
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
||||||
// Report an Issue
|
UIApplication.shared.open(url)
|
||||||
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 {
|
||||||
// Contact Support
|
Text(NSLocalizedString("Report an Issue", comment: ""))
|
||||||
Button(action: {
|
Spacer()
|
||||||
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
Image(systemName: "arrow.up.right")
|
||||||
UIApplication.shared.open(url)
|
.font(.caption)
|
||||||
}
|
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text(NSLocalizedString("Contact Support", comment: ""))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Support")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingPrivacyPolicy) {
|
.sheet(isPresented: $showingPrivacyPolicy) {
|
||||||
@ -119,7 +76,8 @@ struct LegalPrivacySettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
LegalPrivacySettingsView()
|
List {
|
||||||
.cardStyle()
|
LegalPrivacySettingsView()
|
||||||
.padding()
|
}
|
||||||
}
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|||||||
@ -5,97 +5,83 @@
|
|||||||
// Created by Ilyas Hallak on 16.08.25.
|
// Created by Ilyas Hallak on 16.08.25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import os
|
import os
|
||||||
|
|
||||||
struct LoggingConfigurationView: View {
|
struct LoggingConfigurationView: View {
|
||||||
@StateObject private var logConfig = LogConfiguration.shared
|
@StateObject private var logConfig = LogConfiguration.shared
|
||||||
private let logger = Logger.ui
|
private let logger = Logger.ui
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
List {
|
||||||
Form {
|
Section {
|
||||||
Section(header: Text("Global Settings")) {
|
Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.tint(.green)
|
||||||
Text("Global Minimum Level")
|
} header: {
|
||||||
.font(.headline)
|
Text("Logging Status")
|
||||||
|
} footer: {
|
||||||
Picker("Global Level", selection: $logConfig.globalMinLevel) {
|
Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.")
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
}
|
||||||
HStack {
|
|
||||||
Text(level.emoji)
|
if logConfig.isLoggingEnabled {
|
||||||
Text(level.rawValue == 0 ? "Debug" :
|
Section {
|
||||||
level.rawValue == 1 ? "Info" :
|
NavigationLink {
|
||||||
level.rawValue == 2 ? "Notice" :
|
GlobalLogLevelView(logConfig: logConfig)
|
||||||
level.rawValue == 3 ? "Warning" :
|
} label: {
|
||||||
level.rawValue == 4 ? "Error" : "Critical")
|
HStack {
|
||||||
}
|
Label("Global Log Level", systemImage: "slider.horizontal.3")
|
||||||
.tag(level)
|
Spacer()
|
||||||
}
|
Text(levelName(for: logConfig.globalMinLevel))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
|
||||||
|
|
||||||
Text("Logs below this level will be filtered out globally")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
|
Toggle("Show 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")
|
||||||
Section(header: Text("Category-specific Levels")) {
|
} footer: {
|
||||||
ForEach(LogCategory.allCases, id: \.self) { category in
|
Text("Logs below the global level will be filtered out globally")
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
}
|
||||||
HStack {
|
}
|
||||||
Text(category.rawValue)
|
|
||||||
.font(.headline)
|
if logConfig.isLoggingEnabled {
|
||||||
Spacer()
|
Section {
|
||||||
Text(levelName(for: logConfig.getLevel(for: category)))
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||||
.font(.caption)
|
NavigationLink {
|
||||||
.foregroundColor(.secondary)
|
CategoryLogLevelView(category: category, logConfig: logConfig)
|
||||||
}
|
} label: {
|
||||||
|
HStack {
|
||||||
Picker("Level for \(category.rawValue)", selection: Binding(
|
Text(category.rawValue)
|
||||||
get: { logConfig.getLevel(for: category) },
|
Spacer()
|
||||||
set: { logConfig.setLevel($0, for: category) }
|
Text(levelName(for: logConfig.getLevel(for: category)))
|
||||||
)) {
|
.foregroundColor(.secondary)
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
}
|
||||||
HStack {
|
}
|
||||||
Text(level.emoji)
|
}
|
||||||
Text(levelName(for: level))
|
} header: {
|
||||||
}
|
Text("Category-specific Levels")
|
||||||
.tag(level)
|
} footer: {
|
||||||
}
|
Text("Configure log levels for each category individually")
|
||||||
}
|
}
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
}
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
Section {
|
||||||
}
|
Button(role: .destructive) {
|
||||||
}
|
resetToDefaults()
|
||||||
|
} label: {
|
||||||
Section(header: Text("Reset")) {
|
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func levelName(for level: LogLevel) -> String {
|
private func levelName(for level: LogLevel) -> String {
|
||||||
switch level.rawValue {
|
switch level.rawValue {
|
||||||
case 0: return "Debug"
|
case 0: return "Debug"
|
||||||
@ -107,25 +93,140 @@ struct LoggingConfigurationView: View {
|
|||||||
default: return "Unknown"
|
default: return "Unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
logConfig.includeSourceLocation = true
|
logConfig.includeSourceLocation = true
|
||||||
|
|
||||||
logger.info("Logging configuration reset to defaults")
|
logger.info("Logging configuration reset to defaults")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
// MARK: - Global Log Level View
|
||||||
LoggingConfigurationView()
|
|
||||||
|
struct GlobalLogLevelView: View {
|
||||||
|
@ObservedObject var logConfig: LogConfiguration
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
logConfig.globalMinLevel = level
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(levelDescription(for: level))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if logConfig.globalMinLevel == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Global Log Level")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelDescription(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Show all logs including debug information"
|
||||||
|
case 1: return "Show informational messages and above"
|
||||||
|
case 2: return "Show notable events and above"
|
||||||
|
case 3: return "Show warnings and errors only"
|
||||||
|
case 4: return "Show errors and critical issues only"
|
||||||
|
case 5: return "Show only critical issues"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Log Level View
|
||||||
|
|
||||||
|
struct CategoryLogLevelView: View {
|
||||||
|
let category: LogCategory
|
||||||
|
@ObservedObject var logConfig: LogConfiguration
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
logConfig.setLevel(level, for: category)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(levelName(for: level))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(levelDescription(for: level))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if logConfig.getLevel(for: category) == level {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("\(category.rawValue) Logs")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelDescription(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Show all logs including debug information"
|
||||||
|
case 1: return "Show informational messages and above"
|
||||||
|
case 2: return "Show notable events and above"
|
||||||
|
case 3: return "Show warnings and errors only"
|
||||||
|
case 4: return "Show errors and critical issues only"
|
||||||
|
case 5: return "Show only critical issues"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
LoggingConfigurationView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
readeck/UI/Settings/MarkdownContentView.swift
Normal file
35
readeck/UI/Settings/MarkdownContentView.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import MarkdownUI
|
||||||
|
|
||||||
|
/// A custom view that renders Markdown content using the MarkdownUI library.
|
||||||
|
/// This view encapsulates the Markdown rendering logic, making it easy to swap
|
||||||
|
/// the underlying Markdown library if needed in the future.
|
||||||
|
struct MarkdownContentView: View {
|
||||||
|
let content: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Markdown(content)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ScrollView {
|
||||||
|
MarkdownContentView(content: """
|
||||||
|
# Heading 1
|
||||||
|
|
||||||
|
This is a paragraph with **bold** and *italic* text.
|
||||||
|
|
||||||
|
## Heading 2
|
||||||
|
|
||||||
|
- List item 1
|
||||||
|
- List item 2
|
||||||
|
- List item 3
|
||||||
|
|
||||||
|
### Heading 3
|
||||||
|
|
||||||
|
Another paragraph with [a link](https://example.com).
|
||||||
|
""")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,56 +1,14 @@
|
|||||||
import SwiftUI
|
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: 16) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if let attributedString = loadReleaseNotes() {
|
if let markdownContent = loadReleaseNotes() {
|
||||||
Text(attributedString)
|
MarkdownContentView(content: markdownContent)
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
Text("Unable to load release notes")
|
Text("Unable to load release notes")
|
||||||
@ -71,13 +29,12 @@ struct ReleaseNotesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadReleaseNotes() -> AttributedString? {
|
private func loadReleaseNotes() -> String? {
|
||||||
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
||||||
let markdownContent = try? String(contentsOf: url),
|
let markdownContent = try? String(contentsOf: url) else {
|
||||||
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return attributedString
|
return markdownContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,111 +8,90 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsContainerView: View {
|
struct SettingsContainerView: View {
|
||||||
|
|
||||||
private var appVersion: String {
|
private var appVersion: String {
|
||||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
||||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
||||||
return "v\(version) (\(build))"
|
return "v\(version) (\(build))"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
List {
|
||||||
LazyVStack(spacing: 20) {
|
AppearanceSettingsView()
|
||||||
FontSettingsView()
|
|
||||||
.cardStyle()
|
CacheSettingsView()
|
||||||
|
|
||||||
AppearanceSettingsView()
|
SettingsGeneralView()
|
||||||
.cardStyle()
|
|
||||||
|
SettingsServerView()
|
||||||
CacheSettingsView()
|
|
||||||
.cardStyle()
|
LegalPrivacySettingsView()
|
||||||
|
|
||||||
SettingsGeneralView()
|
// Debug-only Logging Configuration
|
||||||
.cardStyle()
|
#if DEBUG
|
||||||
|
if Bundle.main.isDebugBuild {
|
||||||
SettingsServerView()
|
debugSettingsSection
|
||||||
.cardStyle()
|
|
||||||
|
|
||||||
LegalPrivacySettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
|
|
||||||
// Debug-only Logging Configuration
|
|
||||||
if Bundle.main.isDebugBuild {
|
|
||||||
debugSettingsSection
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
#endif
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
|
// App Info Section
|
||||||
AppInfo()
|
appInfoSection
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.listStyle(.insetGrouped)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var debugSettingsSection: some View {
|
private var debugSettingsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
Section {
|
||||||
|
SettingsRowNavigationLink(
|
||||||
|
icon: "list.bullet.rectangle",
|
||||||
|
iconColor: .blue,
|
||||||
|
title: "Debug Logs",
|
||||||
|
subtitle: "View all debug messages"
|
||||||
|
) {
|
||||||
|
DebugLogViewer()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsRowNavigationLink(
|
||||||
|
icon: "slider.horizontal.3",
|
||||||
|
iconColor: .purple,
|
||||||
|
title: "Logging Configuration",
|
||||||
|
subtitle: "Configure log levels and categories"
|
||||||
|
) {
|
||||||
|
LoggingConfigurationView()
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
HStack {
|
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(.caption)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 2)
|
||||||
.background(Color.orange.opacity(0.2))
|
.background(Color.orange.opacity(0.2))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
NavigationLink {
|
}
|
||||||
LoggingConfigurationView()
|
|
||||||
} label: {
|
@ViewBuilder
|
||||||
HStack {
|
private var appInfoSection: some View {
|
||||||
Image(systemName: "doc.text.magnifyingglass")
|
Section {
|
||||||
.foregroundColor(.blue)
|
VStack(spacing: 8) {
|
||||||
.frame(width: 24)
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.foregroundColor(.secondary)
|
||||||
Text("Logging Configuration")
|
Text("Version \(appVersion)")
|
||||||
.foregroundColor(.primary)
|
.font(.footnote)
|
||||||
Text("Configure log levels and categories")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.cardStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func AppInfo() -> some View {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("Version \(appVersion)")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "person.crop.circle")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "person.crop.circle")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
Text("Developer:")
|
Text("Developer:")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@ -123,26 +102,23 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
.frame(maxWidth: .infinity)
|
||||||
Image(systemName: "globe")
|
.padding(.vertical, 8)
|
||||||
.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
|
// Card Modifier für einheitlichen Look (kept for backwards compatibility with other views)
|
||||||
extension View {
|
extension View {
|
||||||
func cardStyle() -> some View {
|
func cardStyle() -> some View {
|
||||||
self
|
self
|
||||||
@ -154,5 +130,7 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsContainerView()
|
NavigationStack {
|
||||||
}
|
SettingsContainerView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -16,15 +16,8 @@ struct SettingsGeneralView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "General Settings".localized, icon: "gear")
|
Section {
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("General")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
// What's New Button
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showReleaseNotes = true
|
showReleaseNotes = true
|
||||||
}) {
|
}) {
|
||||||
@ -39,83 +32,57 @@ 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
|
||||||
// Sync Settings
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Sync Settings")
|
|
||||||
.font(.headline)
|
|
||||||
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
|
||||||
if viewModel.autoSyncEnabled {
|
if viewModel.autoSyncEnabled {
|
||||||
HStack {
|
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
||||||
Text("Sync interval")
|
|
||||||
Spacer()
|
|
||||||
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Sync Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reading Settings
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Reading Settings")
|
|
||||||
.font(.headline)
|
|
||||||
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
|
||||||
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
} header: {
|
||||||
|
Text("Reading Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages
|
|
||||||
if let successMessage = viewModel.successMessage {
|
if let successMessage = viewModel.successMessage {
|
||||||
HStack {
|
Section {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
HStack {
|
||||||
.foregroundColor(.green)
|
Image(systemName: "checkmark.circle.fill")
|
||||||
Text(successMessage)
|
.foregroundColor(.green)
|
||||||
.foregroundColor(.green)
|
Text(successMessage)
|
||||||
.font(.caption)
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
HStack {
|
Section {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
HStack {
|
||||||
.foregroundColor(.red)
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
Text(errorMessage)
|
.foregroundColor(.red)
|
||||||
.foregroundColor(.red)
|
Text(errorMessage)
|
||||||
.font(.caption)
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showReleaseNotes) {
|
.sheet(isPresented: $showReleaseNotes) {
|
||||||
ReleaseNotesView()
|
ReleaseNotesView()
|
||||||
@ -127,7 +94,10 @@ struct SettingsGeneralView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsGeneralView(viewModel: .init(
|
List {
|
||||||
MockUseCaseFactory()
|
SettingsGeneralView(viewModel: .init(
|
||||||
))
|
MockUseCaseFactory()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,189 +11,33 @@ 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 {
|
||||||
VStack(spacing: 20) {
|
Section {
|
||||||
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
|
SettingsRowValue(
|
||||||
.padding(.bottom, 4)
|
icon: "server.rack",
|
||||||
|
title: "Server",
|
||||||
|
value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint
|
||||||
|
)
|
||||||
|
|
||||||
Text(viewModel.isSetupMode ?
|
SettingsRowValue(
|
||||||
"Enter your Readeck server details to get started." :
|
icon: "person.circle.fill",
|
||||||
"Your current server connection and login credentials.")
|
title: "Username",
|
||||||
.font(.body)
|
value: viewModel.username.isEmpty ? "Not set" : viewModel.username
|
||||||
.foregroundColor(.secondary)
|
)
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
|
|
||||||
// Form
|
SettingsRowButton(
|
||||||
VStack(spacing: 16) {
|
icon: "rectangle.portrait.and.arrow.right",
|
||||||
// Server Endpoint
|
iconColor: .red,
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
title: "Logout",
|
||||||
if viewModel.isSetupMode {
|
subtitle: nil,
|
||||||
TextField("",
|
destructive: true
|
||||||
text: $viewModel.endpoint,
|
) {
|
||||||
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
showingLogoutAlert = true
|
||||||
.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) { }
|
||||||
@ -211,22 +55,9 @@ struct SettingsServerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Quick Input Chip Component
|
#Preview {
|
||||||
|
List {
|
||||||
struct QuickInputChip: View {
|
SettingsServerView()
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,4 +44,12 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ struct readeckApp: App {
|
|||||||
if appViewModel.hasFinishedSetup {
|
if appViewModel.hasFinishedSetup {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
} else {
|
} else {
|
||||||
SettingsServerView()
|
OnboardingServerView()
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
readeck/Utils/LogStore.swift
Normal file
145
readeck/Utils/LogStore.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// LogStore.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Log Entry
|
||||||
|
|
||||||
|
struct LogEntry: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let timestamp: Date
|
||||||
|
let level: LogLevel
|
||||||
|
let category: LogCategory
|
||||||
|
let message: String
|
||||||
|
let file: String
|
||||||
|
let function: String
|
||||||
|
let line: Int
|
||||||
|
|
||||||
|
var fileName: String {
|
||||||
|
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedTimestamp: String {
|
||||||
|
DateFormatter.logTimestamp.string(from: timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
timestamp: Date = Date(),
|
||||||
|
level: LogLevel,
|
||||||
|
category: LogCategory,
|
||||||
|
message: String,
|
||||||
|
file: String,
|
||||||
|
function: String,
|
||||||
|
line: Int
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.level = level
|
||||||
|
self.category = category
|
||||||
|
self.message = message
|
||||||
|
self.file = file
|
||||||
|
self.function = function
|
||||||
|
self.line = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log Store
|
||||||
|
|
||||||
|
actor LogStore {
|
||||||
|
static let shared = LogStore()
|
||||||
|
|
||||||
|
private var entries: [LogEntry] = []
|
||||||
|
private let maxEntries: Int
|
||||||
|
|
||||||
|
private init(maxEntries: Int = 1000) {
|
||||||
|
self.maxEntries = maxEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntry(_ entry: LogEntry) {
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
// Keep only the most recent entries
|
||||||
|
if entries.count > maxEntries {
|
||||||
|
entries.removeFirst(entries.count - maxEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEntries() -> [LogEntry] {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEntries(
|
||||||
|
level: LogLevel? = nil,
|
||||||
|
category: LogCategory? = nil,
|
||||||
|
searchText: String? = nil
|
||||||
|
) -> [LogEntry] {
|
||||||
|
var filtered = entries
|
||||||
|
|
||||||
|
if let level = level {
|
||||||
|
filtered = filtered.filter { $0.level == level }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let category = category {
|
||||||
|
filtered = filtered.filter { $0.category == category }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let searchText = searchText, !searchText.isEmpty {
|
||||||
|
filtered = filtered.filter {
|
||||||
|
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
entries.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportAsText() -> String {
|
||||||
|
var text = "Readeck Debug Logs\n"
|
||||||
|
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
|
||||||
|
text += "Total Entries: \(entries.count)\n"
|
||||||
|
text += String(repeating: "=", count: 80) + "\n\n"
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
text += "[\(entry.formattedTimestamp)] "
|
||||||
|
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
|
||||||
|
text += "[\(entry.category.rawValue)] "
|
||||||
|
text += "\(entry.fileName):\(entry.line) "
|
||||||
|
text += "\(entry.function)\n"
|
||||||
|
text += " \(entry.message)\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "DEBUG"
|
||||||
|
case 1: return "INFO"
|
||||||
|
case 2: return "NOTICE"
|
||||||
|
case 3: return "WARNING"
|
||||||
|
case 4: return "ERROR"
|
||||||
|
case 5: return "CRITICAL"
|
||||||
|
default: return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DateFormatter Extension
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
static let exportTimestamp: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
@ -10,14 +10,14 @@ import os
|
|||||||
|
|
||||||
// MARK: - Log Configuration
|
// MARK: - Log Configuration
|
||||||
|
|
||||||
enum LogLevel: Int, CaseIterable {
|
enum LogLevel: Int, CaseIterable, Codable {
|
||||||
case debug = 0
|
case debug = 0
|
||||||
case info = 1
|
case info = 1
|
||||||
case notice = 2
|
case notice = 2
|
||||||
case warning = 3
|
case warning = 3
|
||||||
case error = 4
|
case error = 4
|
||||||
case critical = 5
|
case critical = 5
|
||||||
|
|
||||||
var emoji: String {
|
var emoji: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .debug: return "🔍"
|
case .debug: return "🔍"
|
||||||
@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogCategory: String, CaseIterable {
|
enum LogCategory: String, CaseIterable, Codable {
|
||||||
case network = "Network"
|
case network = "Network"
|
||||||
case ui = "UI"
|
case ui = "UI"
|
||||||
case data = "Data"
|
case data = "Data"
|
||||||
@ -43,13 +43,14 @@ enum LogCategory: String, CaseIterable {
|
|||||||
|
|
||||||
class LogConfiguration: ObservableObject {
|
class LogConfiguration: ObservableObject {
|
||||||
static let shared = LogConfiguration()
|
static let shared = LogConfiguration()
|
||||||
|
|
||||||
@Published private var categoryLevels: [LogCategory: LogLevel] = [:]
|
@Published private var categoryLevels: [LogCategory: LogLevel] = [:]
|
||||||
@Published var globalMinLevel: LogLevel = .debug
|
@Published var globalMinLevel: LogLevel = .debug
|
||||||
@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()
|
||||||
}
|
}
|
||||||
@ -64,6 +65,7 @@ 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
|
||||||
}
|
}
|
||||||
@ -84,6 +86,7 @@ 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() {
|
||||||
@ -96,6 +99,7 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,41 +114,66 @@ struct Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Log Levels
|
// MARK: - Log Levels
|
||||||
|
|
||||||
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
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
|
||||||
Loading…
x
Reference in New Issue
Block a user