Compare commits
No commits in common. "af93c0e79aaee89cadb8a4c754b818109a5e86c4" and "202eba48f3713905e793620c2c96c7729b74d5b4" have entirely different histories.
af93c0e79a
...
202eba48f3
@ -9,7 +9,6 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
||||||
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; };
|
|
||||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@ -87,6 +86,7 @@
|
|||||||
Data/Utils/LabelUtils.swift,
|
Data/Utils/LabelUtils.swift,
|
||||||
Domain/Model/Bookmark.swift,
|
Domain/Model/Bookmark.swift,
|
||||||
Domain/Model/BookmarkLabel.swift,
|
Domain/Model/BookmarkLabel.swift,
|
||||||
|
Logger.swift,
|
||||||
readeck.xcdatamodeld,
|
readeck.xcdatamodeld,
|
||||||
Splash.storyboard,
|
Splash.storyboard,
|
||||||
UI/Components/Constants.swift,
|
UI/Components/Constants.swift,
|
||||||
@ -94,8 +94,6 @@
|
|||||||
UI/Components/TagManagementView.swift,
|
UI/Components/TagManagementView.swift,
|
||||||
UI/Components/UnifiedLabelChip.swift,
|
UI/Components/UnifiedLabelChip.swift,
|
||||||
UI/Utils/NotificationNames.swift,
|
UI/Utils/NotificationNames.swift,
|
||||||
Utils/Logger.swift,
|
|
||||||
Utils/LogStore.swift,
|
|
||||||
);
|
);
|
||||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||||
};
|
};
|
||||||
@ -153,7 +151,6 @@
|
|||||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||||
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -245,7 +242,6 @@
|
|||||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||||
5D9D95482E623668009AF769 /* Kingfisher */,
|
5D9D95482E623668009AF769 /* Kingfisher */,
|
||||||
5D48E6012EB402F50043F90F /* MarkdownUI */,
|
|
||||||
);
|
);
|
||||||
productName = readeck;
|
productName = readeck;
|
||||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||||
@ -337,7 +333,6 @@
|
|||||||
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
||||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
||||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
||||||
@ -442,7 +437,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -475,7 +470,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -630,7 +625,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -674,7 +669,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -859,14 +854,6 @@
|
|||||||
minimumVersion = 1.21.0;
|
minimumVersion = 1.21.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 2.4.1;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
||||||
@ -891,11 +878,6 @@
|
|||||||
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
||||||
productName = netfox;
|
productName = netfox;
|
||||||
};
|
};
|
||||||
5D48E6012EB402F50043F90F /* MarkdownUI */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
|
||||||
productName = MarkdownUI;
|
|
||||||
};
|
|
||||||
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635",
|
"originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "kingfisher",
|
"identity" : "kingfisher",
|
||||||
@ -19,15 +19,6 @@
|
|||||||
"version" : "1.21.0"
|
"version" : "1.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "networkimage",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
|
||||||
"version" : "6.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "r.swift",
|
"identity" : "r.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -46,24 +37,6 @@
|
|||||||
"version" : "1.6.1"
|
"version" : "1.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-cmark",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
|
||||||
"version" : "0.7.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-markdown-ui",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
|
||||||
"version" : "2.4.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "xcodeedit",
|
"identity" : "xcodeedit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -20,7 +20,6 @@ protocol PAPI {
|
|||||||
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
|
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
|
||||||
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
|
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
|
||||||
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
|
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
|
||||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class API: PAPI {
|
class API: PAPI {
|
||||||
@ -487,44 +486,6 @@ class API: PAPI {
|
|||||||
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
|
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
|
||||||
logger.info("Deleting annotation: \(annotationId) from bookmark: \(bookmarkId)")
|
|
||||||
|
|
||||||
let baseURL = await self.baseURL
|
|
||||||
let fullEndpoint = "/api/bookmarks/\(bookmarkId)/annotations/\(annotationId)"
|
|
||||||
|
|
||||||
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
|
|
||||||
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
|
|
||||||
throw APIError.invalidURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "DELETE"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
||||||
|
|
||||||
if let token = await tokenProvider.getToken() {
|
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString)
|
|
||||||
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
|
||||||
logger.error("Invalid HTTP response for DELETE \(url.absoluteString)")
|
|
||||||
throw APIError.invalidResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
|
||||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
|
||||||
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
|
||||||
logger.info("Successfully deleted annotation: \(annotationId)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HTTPMethod: String {
|
enum HTTPMethod: String {
|
||||||
|
|||||||
@ -21,8 +21,4 @@ class AnnotationsRepository: PAnnotationsRepository {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws {
|
|
||||||
try await api.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
protocol PAnnotationsRepository {
|
protocol PAnnotationsRepository {
|
||||||
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
||||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
protocol PDeleteAnnotationUseCase {
|
|
||||||
func execute(bookmarkId: String, annotationId: String) async throws
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeleteAnnotationUseCase: PDeleteAnnotationUseCase {
|
|
||||||
private let repository: PAnnotationsRepository
|
|
||||||
|
|
||||||
init(repository: PAnnotationsRepository) {
|
|
||||||
self.repository = repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute(bookmarkId: String, annotationId: String) async throws {
|
|
||||||
try await repository.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotationId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -40,11 +40,11 @@
|
|||||||
"Tags" = "Labels";
|
"Tags" = "Labels";
|
||||||
|
|
||||||
/* Settings Sections */
|
/* Settings Sections */
|
||||||
"Font Settings" = "Schriftart";
|
"Font Settings" = "Schriftart-Einstellungen";
|
||||||
"Appearance" = "Darstellung";
|
"Appearance" = "Darstellung";
|
||||||
"Cache Settings" = "Cache";
|
"Cache Settings" = "Cache-Einstellungen";
|
||||||
"General Settings" = "Allgemein";
|
"General Settings" = "Allgemeine Einstellungen";
|
||||||
"Server Settings" = "Server";
|
"Server Settings" = "Server-Einstellungen";
|
||||||
"Server Connection" = "Server-Verbindung";
|
"Server Connection" = "Server-Verbindung";
|
||||||
"Open external links in" = "Öffne externe Links in";
|
"Open external links in" = "Öffne externe Links in";
|
||||||
"In App Browser" = "In App Browser";
|
"In App Browser" = "In App Browser";
|
||||||
@ -67,7 +67,7 @@
|
|||||||
"Critical" = "Kritisch";
|
"Critical" = "Kritisch";
|
||||||
"Debug" = "Debug";
|
"Debug" = "Debug";
|
||||||
"DEBUG BUILD" = "DEBUG BUILD";
|
"DEBUG BUILD" = "DEBUG BUILD";
|
||||||
"Debug Settings" = "Debug";
|
"Debug Settings" = "Debug-Einstellungen";
|
||||||
"Delete" = "Löschen";
|
"Delete" = "Löschen";
|
||||||
"Delete Bookmark" = "Lesezeichen löschen";
|
"Delete Bookmark" = "Lesezeichen löschen";
|
||||||
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
|
"Developer: Ilyas Hallak" = "Entwickler: Ilyas Hallak";
|
||||||
@ -80,13 +80,13 @@
|
|||||||
"Finished reading?" = "Fertig gelesen?";
|
"Finished reading?" = "Fertig gelesen?";
|
||||||
"Font" = "Schrift";
|
"Font" = "Schrift";
|
||||||
"Font family" = "Schriftart";
|
"Font family" = "Schriftart";
|
||||||
"Font Settings" = "Schrift";
|
"Font Settings" = "Schrift-Einstellungen";
|
||||||
"Font size" = "Schriftgröße";
|
"Font size" = "Schriftgröße";
|
||||||
"From Bremen with 💚" = "Aus Bremen mit 💚";
|
"From Bremen with 💚" = "Aus Bremen mit 💚";
|
||||||
"General" = "Allgemein";
|
"General" = "Allgemein";
|
||||||
"Global Level" = "Globales Level";
|
"Global Level" = "Globales Level";
|
||||||
"Global Minimum Level" = "Globales Minimum-Level";
|
"Global Minimum Level" = "Globales Minimum-Level";
|
||||||
"Global Settings" = "Global";
|
"Global Settings" = "Globale Einstellungen";
|
||||||
"https://example.com" = "https://example.com";
|
"https://example.com" = "https://example.com";
|
||||||
"https://readeck.example.com" = "https://readeck.example.com";
|
"https://readeck.example.com" = "https://readeck.example.com";
|
||||||
"Include Source Location" = "Quellort einschließen";
|
"Include Source Location" = "Quellort einschließen";
|
||||||
@ -105,8 +105,6 @@
|
|||||||
"More" = "Mehr";
|
"More" = "Mehr";
|
||||||
"New Bookmark" = "Neues Lesezeichen";
|
"New Bookmark" = "Neues Lesezeichen";
|
||||||
"No articles in the queue" = "Keine Artikel in der Warteschlange";
|
"No articles in the queue" = "Keine Artikel in der Warteschlange";
|
||||||
"open_url" = "%@ öffnen";
|
|
||||||
"open_original_page" = "Originalseite öffnen";
|
|
||||||
"No bookmarks" = "Keine Lesezeichen";
|
"No bookmarks" = "Keine Lesezeichen";
|
||||||
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
|
"No bookmarks found in %@." = "Keine Lesezeichen in %@ gefunden.";
|
||||||
"No bookmarks found." = "Keine Lesezeichen gefunden.";
|
"No bookmarks found." = "Keine Lesezeichen gefunden.";
|
||||||
|
|||||||
@ -101,8 +101,6 @@
|
|||||||
"More" = "More";
|
"More" = "More";
|
||||||
"New Bookmark" = "New Bookmark";
|
"New Bookmark" = "New Bookmark";
|
||||||
"No articles in the queue" = "No articles in the queue";
|
"No articles in the queue" = "No articles in the queue";
|
||||||
"open_url" = "Open %@";
|
|
||||||
"open_original_page" = "Open original page";
|
|
||||||
"No bookmarks" = "No bookmarks";
|
"No bookmarks" = "No bookmarks";
|
||||||
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
||||||
"No bookmarks found." = "No bookmarks found.";
|
"No bookmarks found." = "No bookmarks found.";
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import os
|
|||||||
|
|
||||||
// MARK: - Log Configuration
|
// MARK: - Log Configuration
|
||||||
|
|
||||||
enum LogLevel: Int, CaseIterable, Codable {
|
enum LogLevel: Int, CaseIterable {
|
||||||
case debug = 0
|
case debug = 0
|
||||||
case info = 1
|
case info = 1
|
||||||
case notice = 2
|
case notice = 2
|
||||||
@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogCategory: String, CaseIterable, Codable {
|
enum LogCategory: String, CaseIterable {
|
||||||
case network = "Network"
|
case network = "Network"
|
||||||
case ui = "UI"
|
case ui = "UI"
|
||||||
case data = "Data"
|
case data = "Data"
|
||||||
@ -49,7 +49,6 @@ class LogConfiguration: ObservableObject {
|
|||||||
@Published var showPerformanceLogs = true
|
@Published var showPerformanceLogs = true
|
||||||
@Published var showTimestamps = true
|
@Published var showTimestamps = true
|
||||||
@Published var includeSourceLocation = true
|
@Published var includeSourceLocation = true
|
||||||
@Published var isLoggingEnabled = false
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
loadConfiguration()
|
loadConfiguration()
|
||||||
@ -65,7 +64,6 @@ class LogConfiguration: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
|
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
|
||||||
guard isLoggingEnabled else { return false }
|
|
||||||
let categoryLevel = getLevel(for: category)
|
let categoryLevel = getLevel(for: category)
|
||||||
return level.rawValue >= categoryLevel.rawValue
|
return level.rawValue >= categoryLevel.rawValue
|
||||||
}
|
}
|
||||||
@ -86,7 +84,6 @@ class LogConfiguration: ObservableObject {
|
|||||||
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
|
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
|
||||||
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
|
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
|
||||||
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
|
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
|
||||||
isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveConfiguration() {
|
private func saveConfiguration() {
|
||||||
@ -99,7 +96,6 @@ class LogConfiguration: ObservableObject {
|
|||||||
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
|
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
|
||||||
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
|
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
|
||||||
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
|
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
|
||||||
UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,61 +115,36 @@ struct Logger {
|
|||||||
guard config.shouldLog(.debug, for: category) else { return }
|
guard config.shouldLog(.debug, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
|
||||||
logger.debug("\(formattedMessage)")
|
logger.debug("\(formattedMessage)")
|
||||||
storeLog(message: message, level: .debug, file: file, function: function, line: line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.info, for: category) else { return }
|
guard config.shouldLog(.info, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
|
||||||
logger.info("\(formattedMessage)")
|
logger.info("\(formattedMessage)")
|
||||||
storeLog(message: message, level: .info, file: file, function: function, line: line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.notice, for: category) else { return }
|
guard config.shouldLog(.notice, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
|
||||||
logger.notice("\(formattedMessage)")
|
logger.notice("\(formattedMessage)")
|
||||||
storeLog(message: message, level: .notice, file: file, function: function, line: line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.warning, for: category) else { return }
|
guard config.shouldLog(.warning, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
|
||||||
logger.warning("\(formattedMessage)")
|
logger.warning("\(formattedMessage)")
|
||||||
storeLog(message: message, level: .warning, file: file, function: function, line: line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.error, for: category) else { return }
|
guard config.shouldLog(.error, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
|
||||||
logger.error("\(formattedMessage)")
|
logger.error("\(formattedMessage)")
|
||||||
storeLog(message: message, level: .error, file: file, function: function, line: line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
guard config.shouldLog(.critical, for: category) else { return }
|
guard config.shouldLog(.critical, for: category) else { return }
|
||||||
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
|
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
|
||||||
logger.critical("\(formattedMessage)")
|
logger.critical("\(formattedMessage)")
|
||||||
storeLog(message: message, level: .critical, file: file, function: function, line: line)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Store Log
|
|
||||||
|
|
||||||
private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) {
|
|
||||||
#if DEBUG
|
|
||||||
guard config.isLoggingEnabled else { return }
|
|
||||||
let entry = LogEntry(
|
|
||||||
level: level,
|
|
||||||
category: category,
|
|
||||||
message: message,
|
|
||||||
file: file,
|
|
||||||
function: function,
|
|
||||||
line: line
|
|
||||||
)
|
|
||||||
Task {
|
|
||||||
await LogStore.shared.addEntry(entry)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Convenience Methods
|
// MARK: - Convenience Methods
|
||||||
@ -18,17 +18,22 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi
|
|||||||
### Performance Improvements
|
### Performance Improvements
|
||||||
|
|
||||||
- **Dramatically faster label loading** - especially with 1000+ labels
|
- **Dramatically faster label loading** - especially with 1000+ labels
|
||||||
- Labels now load instantly, even without internet connection
|
- Labels now load instantly from local cache, then sync in background
|
||||||
- Share Extension loads much faster
|
- Optimized label management to prevent crashes and lag
|
||||||
- Better performance when working with many labels
|
- Share Extension now loads labels without delay
|
||||||
- Improved overall app stability
|
- Reduced memory usage when working with large label collections
|
||||||
|
- Better offline support - labels always available even without internet
|
||||||
|
|
||||||
### Fixes & Improvements
|
### Fixes & Improvements
|
||||||
|
|
||||||
- Better color consistency throughout the app
|
- Centralized color management for consistent appearance
|
||||||
- Improved text selection in articles
|
- Improved annotation creation workflow
|
||||||
- Better formatted release notes
|
- Better text selection handling in article view
|
||||||
- Various bug fixes and stability improvements
|
- Implemented lazy loading for label lists
|
||||||
|
- Switched to Core Data as primary source for labels
|
||||||
|
- Batch operations for faster database queries
|
||||||
|
- Background sync to keep labels up-to-date without blocking the UI
|
||||||
|
- Fixed duplicate ID warnings in label lists
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -64,15 +64,6 @@ struct AnnotationsListView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteAnnotation(bookmarkId: bookmarkId, annotationId: annotation.id)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .error:
|
case .error:
|
||||||
@ -83,10 +74,8 @@ struct AnnotationsListView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
class AnnotationsListViewModel {
|
class AnnotationsListViewModel {
|
||||||
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
|
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
|
||||||
private let deleteAnnotationUseCase: PDeleteAnnotationUseCase
|
|
||||||
|
|
||||||
var annotations: [Annotation] = []
|
var annotations: [Annotation] = []
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
@ -12,7 +11,6 @@ class AnnotationsListViewModel {
|
|||||||
|
|
||||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
|
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
|
||||||
self.deleteAnnotationUseCase = factory.makeDeleteAnnotationUseCase()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -28,15 +26,4 @@ class AnnotationsListViewModel {
|
|||||||
showErrorAlert = true
|
showErrorAlert = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async {
|
|
||||||
do {
|
|
||||||
try await deleteAnnotationUseCase.execute(bookmarkId: bookmarkId, annotationId: annotationId)
|
|
||||||
annotations.removeAll { $0.id == annotationId }
|
|
||||||
} catch {
|
|
||||||
errorMessage = "Failed to delete annotation"
|
|
||||||
showErrorAlert = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,16 +101,6 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
endSelector: endSelector
|
endSelector: endSelector
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onScrollToPosition: { position in
|
|
||||||
// Calculate scroll position: add header height and webview offset
|
|
||||||
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
|
|
||||||
let targetPosition = imageHeight + position
|
|
||||||
|
|
||||||
// Scroll to the annotation
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
||||||
scrollPosition = ScrollPosition(y: targetPosition)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
@ -126,7 +116,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -402,7 +392,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -456,7 +446,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||||
}) {
|
}) {
|
||||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -411,7 +411,7 @@ struct BookmarkDetailView2: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: viewModel.bookmarkDetail.url, urlOpener: appSettings.urlOpener)
|
||||||
}) {
|
}) {
|
||||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@ -475,16 +475,6 @@ struct BookmarkDetailView2: View {
|
|||||||
endSelector: endSelector
|
endSelector: endSelector
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onScrollToPosition: { position in
|
|
||||||
// Calculate scroll position: add header height and webview offset
|
|
||||||
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
|
|
||||||
let targetPosition = imageHeight + position
|
|
||||||
|
|
||||||
// Scroll to the annotation
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
||||||
scrollPosition = ScrollPosition(y: targetPosition)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
@ -501,7 +491,7 @@ struct BookmarkDetailView2: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text(URLUtil.openUrlLabel(for: viewModel.bookmarkDetail.url))
|
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@ -254,7 +254,7 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||||
}
|
}
|
||||||
@ -335,7 +335,7 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,6 @@ struct NativeWebView: View {
|
|||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
var selectedAnnotationId: String?
|
var selectedAnnotationId: String?
|
||||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||||
var onScrollToPosition: ((CGFloat) -> Void)? = nil
|
|
||||||
|
|
||||||
@State private var webPage = WebPage()
|
@State private var webPage = WebPage()
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@ -24,7 +23,6 @@ struct NativeWebView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
setupAnnotationMessageHandler()
|
setupAnnotationMessageHandler()
|
||||||
setupScrollToPositionHandler()
|
|
||||||
}
|
}
|
||||||
.onChange(of: htmlContent) { _, _ in
|
.onChange(of: htmlContent) { _, _ in
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
@ -85,38 +83,6 @@ struct NativeWebView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupScrollToPositionHandler() {
|
|
||||||
guard let onScrollToPosition = onScrollToPosition else { return }
|
|
||||||
|
|
||||||
// Poll for scroll position messages from JavaScript
|
|
||||||
Task { @MainActor in
|
|
||||||
let page = webPage
|
|
||||||
|
|
||||||
while true {
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
|
|
||||||
|
|
||||||
let script = """
|
|
||||||
return (function() {
|
|
||||||
if (window.__pendingScrollPosition !== undefined) {
|
|
||||||
const position = window.__pendingScrollPosition;
|
|
||||||
window.__pendingScrollPosition = undefined;
|
|
||||||
return position;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
do {
|
|
||||||
if let position = try await page.callJavaScript(script) as? Double {
|
|
||||||
onScrollToPosition(CGFloat(position))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently continue polling
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateContentHeightWithJS() async {
|
private func updateContentHeightWithJS() async {
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
|
|
||||||
@ -661,15 +627,8 @@ struct NativeWebView: View {
|
|||||||
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
selectedElement.classList.add('selected');
|
selectedElement.classList.add('selected');
|
||||||
|
|
||||||
// Get the element's position relative to the document
|
|
||||||
const rect = selectedElement.getBoundingClientRect();
|
|
||||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
||||||
const elementTop = rect.top + scrollTop;
|
|
||||||
|
|
||||||
// Send position to Swift via polling mechanism
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.__pendingScrollPosition = elementTop;
|
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,308 +0,0 @@
|
|||||||
//
|
|
||||||
// SettingsRow.swift
|
|
||||||
// readeck
|
|
||||||
//
|
|
||||||
// Created by Ilyas Hallak on 31.10.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Settings Row with Navigation Link
|
|
||||||
struct SettingsRowNavigationLink<Destination: View>: View {
|
|
||||||
let icon: String?
|
|
||||||
let iconColor: Color
|
|
||||||
let title: String
|
|
||||||
let subtitle: String?
|
|
||||||
let destination: Destination
|
|
||||||
|
|
||||||
init(
|
|
||||||
icon: String? = nil,
|
|
||||||
iconColor: Color = .accentColor,
|
|
||||||
title: String,
|
|
||||||
subtitle: String? = nil,
|
|
||||||
@ViewBuilder destination: () -> Destination
|
|
||||||
) {
|
|
||||||
self.icon = icon
|
|
||||||
self.iconColor = iconColor
|
|
||||||
self.title = title
|
|
||||||
self.subtitle = subtitle
|
|
||||||
self.destination = destination()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationLink(destination: destination) {
|
|
||||||
SettingsRowLabel(
|
|
||||||
icon: icon,
|
|
||||||
iconColor: iconColor,
|
|
||||||
title: title,
|
|
||||||
subtitle: subtitle
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Settings Row with Toggle
|
|
||||||
struct SettingsRowToggle: View {
|
|
||||||
let icon: String?
|
|
||||||
let iconColor: Color
|
|
||||||
let title: String
|
|
||||||
let subtitle: String?
|
|
||||||
@Binding var isOn: Bool
|
|
||||||
|
|
||||||
init(
|
|
||||||
icon: String? = nil,
|
|
||||||
iconColor: Color = .accentColor,
|
|
||||||
title: String,
|
|
||||||
subtitle: String? = nil,
|
|
||||||
isOn: Binding<Bool>
|
|
||||||
) {
|
|
||||||
self.icon = icon
|
|
||||||
self.iconColor = iconColor
|
|
||||||
self.title = title
|
|
||||||
self.subtitle = subtitle
|
|
||||||
self._isOn = isOn
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
SettingsRowLabel(
|
|
||||||
icon: icon,
|
|
||||||
iconColor: iconColor,
|
|
||||||
title: title,
|
|
||||||
subtitle: subtitle
|
|
||||||
)
|
|
||||||
Toggle("", isOn: $isOn)
|
|
||||||
.labelsHidden()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Settings Row with Value Display
|
|
||||||
struct SettingsRowValue: View {
|
|
||||||
let icon: String?
|
|
||||||
let iconColor: Color
|
|
||||||
let title: String
|
|
||||||
let value: String
|
|
||||||
let valueColor: Color
|
|
||||||
|
|
||||||
init(
|
|
||||||
icon: String? = nil,
|
|
||||||
iconColor: Color = .accentColor,
|
|
||||||
title: String,
|
|
||||||
value: String,
|
|
||||||
valueColor: Color = .secondary
|
|
||||||
) {
|
|
||||||
self.icon = icon
|
|
||||||
self.iconColor = iconColor
|
|
||||||
self.title = title
|
|
||||||
self.value = value
|
|
||||||
self.valueColor = valueColor
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
SettingsRowLabel(
|
|
||||||
icon: icon,
|
|
||||||
iconColor: iconColor,
|
|
||||||
title: title,
|
|
||||||
subtitle: nil
|
|
||||||
)
|
|
||||||
Spacer()
|
|
||||||
Text(value)
|
|
||||||
.foregroundColor(valueColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Settings Row Button (for actions)
|
|
||||||
struct SettingsRowButton: View {
|
|
||||||
let icon: String?
|
|
||||||
let iconColor: Color
|
|
||||||
let title: String
|
|
||||||
let subtitle: String?
|
|
||||||
let destructive: Bool
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
init(
|
|
||||||
icon: String? = nil,
|
|
||||||
iconColor: Color = .accentColor,
|
|
||||||
title: String,
|
|
||||||
subtitle: String? = nil,
|
|
||||||
destructive: Bool = false,
|
|
||||||
action: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
self.icon = icon
|
|
||||||
self.iconColor = iconColor
|
|
||||||
self.title = title
|
|
||||||
self.subtitle = subtitle
|
|
||||||
self.destructive = destructive
|
|
||||||
self.action = action
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
SettingsRowLabel(
|
|
||||||
icon: icon,
|
|
||||||
iconColor: destructive ? .red : iconColor,
|
|
||||||
title: title,
|
|
||||||
subtitle: subtitle,
|
|
||||||
titleColor: destructive ? .red : .primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Settings Row with Picker
|
|
||||||
struct SettingsRowPicker<T: Hashable>: View {
|
|
||||||
let icon: String?
|
|
||||||
let iconColor: Color
|
|
||||||
let title: String
|
|
||||||
let selection: Binding<T>
|
|
||||||
let options: [(value: T, label: String)]
|
|
||||||
|
|
||||||
init(
|
|
||||||
icon: String? = nil,
|
|
||||||
iconColor: Color = .accentColor,
|
|
||||||
title: String,
|
|
||||||
selection: Binding<T>,
|
|
||||||
options: [(value: T, label: String)]
|
|
||||||
) {
|
|
||||||
self.icon = icon
|
|
||||||
self.iconColor = iconColor
|
|
||||||
self.title = title
|
|
||||||
self.selection = selection
|
|
||||||
self.options = options
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
SettingsRowLabel(
|
|
||||||
icon: icon,
|
|
||||||
iconColor: iconColor,
|
|
||||||
title: title,
|
|
||||||
subtitle: nil
|
|
||||||
)
|
|
||||||
Spacer()
|
|
||||||
Picker("", selection: selection) {
|
|
||||||
ForEach(options, id: \.value) { option in
|
|
||||||
Text(option.label).tag(option.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Settings Row Label (internal component)
|
|
||||||
struct SettingsRowLabel: View {
|
|
||||||
let icon: String?
|
|
||||||
let iconColor: Color
|
|
||||||
let title: String
|
|
||||||
let subtitle: String?
|
|
||||||
let titleColor: Color
|
|
||||||
|
|
||||||
init(
|
|
||||||
icon: String?,
|
|
||||||
iconColor: Color,
|
|
||||||
title: String,
|
|
||||||
subtitle: String?,
|
|
||||||
titleColor: Color = .primary
|
|
||||||
) {
|
|
||||||
self.icon = icon
|
|
||||||
self.iconColor = iconColor
|
|
||||||
self.title = title
|
|
||||||
self.subtitle = subtitle
|
|
||||||
self.titleColor = titleColor
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
if let icon = icon {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(iconColor)
|
|
||||||
.frame(width: 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(title)
|
|
||||||
.foregroundColor(titleColor)
|
|
||||||
|
|
||||||
if let subtitle = subtitle {
|
|
||||||
Text(subtitle)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Previews
|
|
||||||
#Preview("Navigation Link") {
|
|
||||||
List {
|
|
||||||
SettingsRowNavigationLink(
|
|
||||||
icon: "paintbrush",
|
|
||||||
title: "App Icon",
|
|
||||||
subtitle: nil
|
|
||||||
) {
|
|
||||||
Text("Detail View")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Toggle") {
|
|
||||||
List {
|
|
||||||
SettingsRowToggle(
|
|
||||||
icon: "speaker.wave.2",
|
|
||||||
title: "Read Aloud Feature",
|
|
||||||
subtitle: "Text-to-Speech functionality",
|
|
||||||
isOn: .constant(true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Value Display") {
|
|
||||||
List {
|
|
||||||
SettingsRowValue(
|
|
||||||
icon: "paintbrush.fill",
|
|
||||||
iconColor: .purple,
|
|
||||||
title: "Tint Color",
|
|
||||||
value: "Purple"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Button") {
|
|
||||||
List {
|
|
||||||
SettingsRowButton(
|
|
||||||
icon: "trash",
|
|
||||||
iconColor: .red,
|
|
||||||
title: "Clear Cache",
|
|
||||||
subtitle: "Remove all cached images",
|
|
||||||
destructive: true
|
|
||||||
) {
|
|
||||||
print("Clear cache tapped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Picker") {
|
|
||||||
List {
|
|
||||||
SettingsRowPicker(
|
|
||||||
icon: "textformat",
|
|
||||||
title: "Font Family",
|
|
||||||
selection: .constant("System"),
|
|
||||||
options: [
|
|
||||||
("System", "System"),
|
|
||||||
("Serif", "Serif"),
|
|
||||||
("Monospace", "Monospace")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@ struct WebView: UIViewRepresentable {
|
|||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
var selectedAnnotationId: String?
|
var selectedAnnotationId: String?
|
||||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||||
var onScrollToPosition: ((CGFloat) -> Void)? = nil
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
@ -32,11 +31,9 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
|
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollToPosition")
|
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||||
context.coordinator.onScrollToPosition = onScrollToPosition
|
|
||||||
context.coordinator.webView = webView
|
context.coordinator.webView = webView
|
||||||
|
|
||||||
return webView
|
return webView
|
||||||
@ -46,7 +43,6 @@ struct WebView: UIViewRepresentable {
|
|||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||||
context.coordinator.onScrollToPosition = onScrollToPosition
|
|
||||||
|
|
||||||
let isDarkMode = colorScheme == .dark
|
let isDarkMode = colorScheme == .dark
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||||
@ -336,7 +332,6 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition")
|
|
||||||
webView.loadHTMLString("", baseURL: nil)
|
webView.loadHTMLString("", baseURL: nil)
|
||||||
coordinator.cleanup()
|
coordinator.cleanup()
|
||||||
}
|
}
|
||||||
@ -384,15 +379,8 @@ struct WebView: UIViewRepresentable {
|
|||||||
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
selectedElement.classList.add('selected');
|
selectedElement.classList.add('selected');
|
||||||
|
|
||||||
// Get the element's position relative to the document
|
|
||||||
const rect = selectedElement.getBoundingClientRect();
|
|
||||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
||||||
const elementTop = rect.top + scrollTop;
|
|
||||||
|
|
||||||
// Send position to Swift
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop);
|
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -659,7 +647,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
var onHeightChange: ((CGFloat) -> Void)?
|
var onHeightChange: ((CGFloat) -> Void)?
|
||||||
var onScroll: ((Double) -> Void)?
|
var onScroll: ((Double) -> Void)?
|
||||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
|
||||||
var onScrollToPosition: ((CGFloat) -> Void)?
|
|
||||||
|
|
||||||
// WebView reference
|
// WebView reference
|
||||||
weak var webView: WKWebView?
|
weak var webView: WKWebView?
|
||||||
@ -715,11 +702,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
|
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if message.name == "scrollToPosition", let position = message.body as? Double {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onScrollToPosition?(CGFloat(position))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleHeightUpdate(height: CGFloat) {
|
private func handleHeightUpdate(height: CGFloat) {
|
||||||
@ -796,6 +778,5 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
onHeightChange = nil
|
onHeightChange = nil
|
||||||
onScroll = nil
|
onScroll = nil
|
||||||
onAnnotationCreated = nil
|
onAnnotationCreated = nil
|
||||||
onScrollToPosition = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,6 @@ protocol UseCaseFactory {
|
|||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||||
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
||||||
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -126,8 +125,4 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
|
|
||||||
return DeleteAnnotationUseCase(repository: annotationsRepository)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,10 +92,6 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
MockGetBookmarkAnnotationsUseCase()
|
MockGetBookmarkAnnotationsUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
|
|
||||||
MockDeleteAnnotationUseCase()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -254,12 +250,6 @@ class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase {
|
|
||||||
func execute(bookmarkId: String, annotationId: String) async throws {
|
|
||||||
// Mock implementation - do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Bookmark {
|
extension Bookmark {
|
||||||
static let mock: Bookmark = .init(
|
static let mock: Bookmark = .init(
|
||||||
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
||||||
|
|||||||
@ -1,175 +0,0 @@
|
|||||||
//
|
|
||||||
// OnboardingServerView.swift
|
|
||||||
// readeck
|
|
||||||
//
|
|
||||||
// Created by Ilyas Hallak on 31.10.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct OnboardingServerView: View {
|
|
||||||
@State private var viewModel = SettingsServerViewModel()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
SectionHeader(title: "Server Settings".localized, icon: "server.rack")
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
Text("Enter your Readeck server details to get started.")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
|
|
||||||
// Form
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
// Server Endpoint
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
TextField("",
|
|
||||||
text: $viewModel.endpoint,
|
|
||||||
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.keyboardType(.URL)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.onChange(of: viewModel.endpoint) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick Input Chips
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
QuickInputChip(text: "http://", action: {
|
|
||||||
if !viewModel.endpoint.starts(with: "http") {
|
|
||||||
viewModel.endpoint = "http://" + viewModel.endpoint
|
|
||||||
}
|
|
||||||
})
|
|
||||||
QuickInputChip(text: "https://", action: {
|
|
||||||
if !viewModel.endpoint.starts(with: "http") {
|
|
||||||
viewModel.endpoint = "https://" + viewModel.endpoint
|
|
||||||
}
|
|
||||||
})
|
|
||||||
QuickInputChip(text: "192.168.", action: {
|
|
||||||
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
|
|
||||||
if viewModel.endpoint.starts(with: "http") {
|
|
||||||
viewModel.endpoint += "192.168."
|
|
||||||
} else {
|
|
||||||
viewModel.endpoint = "http://192.168."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
QuickInputChip(text: ":8000", action: {
|
|
||||||
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
|
|
||||||
viewModel.endpoint += ":8000"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Username
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
TextField("",
|
|
||||||
text: $viewModel.username,
|
|
||||||
prompt: Text("Username").foregroundColor(.secondary))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.onChange(of: viewModel.username) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
SecureField("",
|
|
||||||
text: $viewModel.password,
|
|
||||||
prompt: Text("Password").foregroundColor(.secondary))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.onChange(of: viewModel.password) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let successMessage = viewModel.successMessage {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.green)
|
|
||||||
Text(successMessage)
|
|
||||||
.foregroundColor(.green)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Button(action: {
|
|
||||||
Task {
|
|
||||||
await viewModel.saveServerSettings()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
}
|
|
||||||
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await viewModel.loadServerSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Quick Input Chip Component
|
|
||||||
|
|
||||||
struct QuickInputChip: View {
|
|
||||||
let text: String
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
Text(text)
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color(.systemGray5))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
OnboardingServerView()
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
@ -3,116 +3,58 @@ import SwiftUI
|
|||||||
struct AppearanceSettingsView: View {
|
struct AppearanceSettingsView: View {
|
||||||
@State private var selectedCardLayout: CardLayoutStyle = .magazine
|
@State private var selectedCardLayout: CardLayoutStyle = .magazine
|
||||||
@State private var selectedTheme: Theme = .system
|
@State private var selectedTheme: Theme = .system
|
||||||
@State private var fontViewModel: FontSettingsViewModel
|
|
||||||
@State private var generalViewModel: SettingsGeneralViewModel
|
|
||||||
|
|
||||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||||
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
||||||
private let settingsRepository: PSettingsRepository
|
private let settingsRepository: PSettingsRepository
|
||||||
|
|
||||||
init(
|
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
factory: UseCaseFactory = DefaultUseCaseFactory.shared,
|
|
||||||
fontViewModel: FontSettingsViewModel = FontSettingsViewModel(),
|
|
||||||
generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()
|
|
||||||
) {
|
|
||||||
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||||
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
||||||
self.settingsRepository = SettingsRepository()
|
self.settingsRepository = SettingsRepository()
|
||||||
self.fontViewModel = fontViewModel
|
|
||||||
self.generalViewModel = generalViewModel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
VStack(spacing: 20) {
|
||||||
Section {
|
SectionHeader(title: "Appearance".localized, icon: "paintbrush")
|
||||||
// Font Family
|
.padding(.bottom, 4)
|
||||||
Picker("Font family", selection: $fontViewModel.selectedFontFamily) {
|
|
||||||
ForEach(FontFamily.allCases, id: \.self) { family in
|
|
||||||
Text(family.displayName).tag(family)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: fontViewModel.selectedFontFamily) {
|
|
||||||
Task {
|
|
||||||
await fontViewModel.saveFontSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font Size
|
// Theme Section
|
||||||
Picker("Font size", selection: $fontViewModel.selectedFontSize) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ForEach(FontSize.allCases, id: \.self) { size in
|
Text("Theme")
|
||||||
Text(size.displayName).tag(size)
|
.font(.headline)
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.onChange(of: fontViewModel.selectedFontSize) {
|
|
||||||
Task {
|
|
||||||
await fontViewModel.saveFontSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font Preview - direkt in der gleichen Section
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("readeck Bookmark Title")
|
|
||||||
.font(fontViewModel.previewTitleFont)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
|
||||||
.font(fontViewModel.previewBodyFont)
|
|
||||||
.lineLimit(3)
|
|
||||||
|
|
||||||
Text("12 min • Today • example.com")
|
|
||||||
.font(fontViewModel.previewCaptionFont)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.listRowBackground(Color(.systemGray6))
|
|
||||||
|
|
||||||
// Theme Picker (Menu statt Segmented)
|
|
||||||
Picker("Theme", selection: $selectedTheme) {
|
Picker("Theme", selection: $selectedTheme) {
|
||||||
ForEach(Theme.allCases, id: \.self) { theme in
|
ForEach(Theme.allCases, id: \.self) { theme in
|
||||||
Text(theme.displayName).tag(theme)
|
Text(theme.displayName).tag(theme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
.onChange(of: selectedTheme) {
|
.onChange(of: selectedTheme) {
|
||||||
saveThemeSettings()
|
saveThemeSettings()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Card Layout als NavigationLink
|
Divider()
|
||||||
NavigationLink {
|
|
||||||
CardLayoutSelectionView(
|
|
||||||
selectedCardLayout: $selectedCardLayout,
|
|
||||||
onSave: saveCardLayoutSettings
|
|
||||||
)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Card Layout")
|
|
||||||
Spacer()
|
|
||||||
Text(selectedCardLayout.displayName)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open external links in
|
// Card Layout Section
|
||||||
Picker("Open links in", selection: $generalViewModel.urlOpener) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
|
Text("Card Layout")
|
||||||
Text(urlOpener.displayName).tag(urlOpener)
|
.font(.headline)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
||||||
|
CardLayoutPreview(
|
||||||
|
layout: layout,
|
||||||
|
isSelected: selectedCardLayout == layout
|
||||||
|
) {
|
||||||
|
selectedCardLayout = layout
|
||||||
|
saveCardLayoutSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: generalViewModel.urlOpener) {
|
|
||||||
Task {
|
|
||||||
await generalViewModel.saveGeneralSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Appearance")
|
|
||||||
} footer: {
|
|
||||||
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.onAppear {
|
||||||
await fontViewModel.loadFontSettings()
|
|
||||||
await generalViewModel.loadGeneralSettings()
|
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -154,11 +96,139 @@ struct AppearanceSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NavigationStack {
|
struct CardLayoutPreview: View {
|
||||||
List {
|
let layout: CardLayoutStyle
|
||||||
AppearanceSettingsView()
|
let isSelected: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Visual Preview
|
||||||
|
switch layout {
|
||||||
|
case .compact:
|
||||||
|
// Compact: Small image on left, content on right
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 60)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.4))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 50)
|
||||||
|
|
||||||
|
case .magazine:
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 5)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 40)
|
||||||
|
|
||||||
|
Text("Fixed 140px")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 65)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||||
|
|
||||||
|
case .natural:
|
||||||
|
VStack(spacing: 3) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(height: 38)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 5)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 35)
|
||||||
|
|
||||||
|
Text("Original ratio")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 75) // Höher als Magazine
|
||||||
|
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(layout.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(layout.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AppearanceSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|||||||
@ -8,65 +8,77 @@ struct CacheSettingsView: View {
|
|||||||
@State private var showClearAlert: Bool = false
|
@State private var showClearAlert: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
VStack(spacing: 20) {
|
||||||
HStack {
|
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.padding(.bottom, 4)
|
||||||
Text("Current Cache Size")
|
|
||||||
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button("Refresh") {
|
|
||||||
updateCacheSize()
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Max Cache Size")
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(maxCacheSize)) MB")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
|
||||||
Text("Max Cache Size")
|
|
||||||
}
|
|
||||||
.onChange(of: maxCacheSize) { _, newValue in
|
|
||||||
updateMaxCacheSize(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showClearAlert = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if isClearing {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Clear Cache")
|
Text("Current Cache Size")
|
||||||
.foregroundColor(isClearing ? .secondary : .red)
|
.foregroundColor(.primary)
|
||||||
Text("Remove all cached images")
|
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Refresh") {
|
||||||
|
updateCacheSize()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(maxCacheSize)) MB")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
}
|
||||||
|
.onChange(of: maxCacheSize) { _, newValue in
|
||||||
|
updateMaxCacheSize(newValue)
|
||||||
|
}
|
||||||
|
.accentColor(.blue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showClearAlert = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if isClearing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.frame(width: 24)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.frame(width: 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Clear Cache")
|
||||||
|
.foregroundColor(isClearing ? .secondary : .red)
|
||||||
|
Text("Remove all cached images")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isClearing)
|
||||||
}
|
}
|
||||||
.disabled(isClearing)
|
|
||||||
} header: {
|
|
||||||
Text("Cache Settings")
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateCacheSize()
|
updateCacheSize()
|
||||||
@ -130,8 +142,7 @@ struct CacheSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
List {
|
CacheSettingsView()
|
||||||
CacheSettingsView()
|
.cardStyle()
|
||||||
}
|
.padding()
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
}
|
||||||
@ -1,171 +0,0 @@
|
|||||||
//
|
|
||||||
// CardLayoutSelectionView.swift
|
|
||||||
// readeck
|
|
||||||
//
|
|
||||||
// Created by Ilyas Hallak on 31.10.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CardLayoutSelectionView: View {
|
|
||||||
@Binding var selectedCardLayout: CardLayoutStyle
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
let onSave: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
|
||||||
CardLayoutPreview(
|
|
||||||
layout: layout,
|
|
||||||
isSelected: selectedCardLayout == layout
|
|
||||||
) {
|
|
||||||
selectedCardLayout = layout
|
|
||||||
onSave()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.navigationTitle("Card Layout")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CardLayoutPreview: View {
|
|
||||||
let layout: CardLayoutStyle
|
|
||||||
let isSelected: Bool
|
|
||||||
let onSelect: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: onSelect) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
// Visual Preview
|
|
||||||
switch layout {
|
|
||||||
case .compact:
|
|
||||||
// Compact: Small image on left, content on right
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(width: 24, height: 24)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 6)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 60)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.4))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 50)
|
|
||||||
|
|
||||||
case .magazine:
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(height: 24)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 5)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 40)
|
|
||||||
|
|
||||||
Text("Fixed 140px")
|
|
||||||
.font(.system(size: 7))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
.padding(6)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 65)
|
|
||||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
|
||||||
|
|
||||||
case .natural:
|
|
||||||
VStack(spacing: 3) {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(height: 38)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 5)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 35)
|
|
||||||
|
|
||||||
Text("Original ratio")
|
|
||||||
.font(.system(size: 7))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
.padding(6)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 75)
|
|
||||||
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(layout.displayName)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Text(layout.description)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if isSelected {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
.font(.title2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NavigationStack {
|
|
||||||
CardLayoutSelectionView(
|
|
||||||
selectedCardLayout: .constant(.magazine),
|
|
||||||
onSave: {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,444 +0,0 @@
|
|||||||
//
|
|
||||||
// DebugLogViewer.swift
|
|
||||||
// readeck
|
|
||||||
//
|
|
||||||
// Created by Ilyas Hallak on 01.11.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct DebugLogViewer: View {
|
|
||||||
@State private var entries: [LogEntry] = []
|
|
||||||
@State private var selectedLevel: LogLevel?
|
|
||||||
@State private var selectedCategory: LogCategory?
|
|
||||||
@State private var searchText = ""
|
|
||||||
@State private var showShareSheet = false
|
|
||||||
@State private var exportText = ""
|
|
||||||
@State private var autoScroll = true
|
|
||||||
@State private var showFilters = false
|
|
||||||
@StateObject private var logConfig = LogConfiguration.shared
|
|
||||||
|
|
||||||
private let logger = Logger.ui
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Logging Disabled Warning
|
|
||||||
if !logConfig.isLoggingEnabled {
|
|
||||||
loggingDisabledBanner
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter Bar
|
|
||||||
if showFilters {
|
|
||||||
filterBar
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log List
|
|
||||||
if filteredEntries.isEmpty {
|
|
||||||
emptyState
|
|
||||||
} else {
|
|
||||||
ScrollViewReader { proxy in
|
|
||||||
List {
|
|
||||||
ForEach(filteredEntries) { entry in
|
|
||||||
LogEntryRow(entry: entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.onChange(of: entries.count) { oldValue, newValue in
|
|
||||||
if autoScroll, let lastEntry = filteredEntries.last {
|
|
||||||
withAnimation {
|
|
||||||
proxy.scrollTo(lastEntry.id, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Debug Logs")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
||||||
Menu {
|
|
||||||
Button {
|
|
||||||
showFilters.toggle()
|
|
||||||
} label: {
|
|
||||||
Label(
|
|
||||||
showFilters ? "Hide Filters" : "Show Filters",
|
|
||||||
systemImage: "line.3.horizontal.decrease.circle"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
autoScroll.toggle()
|
|
||||||
} label: {
|
|
||||||
Label(
|
|
||||||
autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll",
|
|
||||||
systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await refreshLogs()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await exportLogs()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Export Logs", systemImage: "square.and.arrow.up")
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await clearLogs()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Clear All Logs", systemImage: "trash")
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "ellipsis.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.searchable(text: $searchText, prompt: "Search logs")
|
|
||||||
.task {
|
|
||||||
await refreshLogs()
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showShareSheet) {
|
|
||||||
ActivityView(activityItems: [exportText])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var filterBar: some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
HStack {
|
|
||||||
Text("Filters")
|
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
// Level Filter
|
|
||||||
Menu {
|
|
||||||
Button("All Levels") {
|
|
||||||
selectedLevel = nil
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
|
||||||
Button {
|
|
||||||
selectedLevel = level
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(levelName(for: level))
|
|
||||||
if selectedLevel == level {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "slider.horizontal.3")
|
|
||||||
Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level")
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
|
||||||
.foregroundColor(selectedLevel != nil ? .accentColor : .primary)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category Filter
|
|
||||||
Menu {
|
|
||||||
Button("All Categories") {
|
|
||||||
selectedCategory = nil
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
ForEach(LogCategory.allCases, id: \.self) { category in
|
|
||||||
Button {
|
|
||||||
selectedCategory = category
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(category.rawValue)
|
|
||||||
if selectedCategory == category {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "tag")
|
|
||||||
Text(selectedCategory?.rawValue ?? "Category")
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
|
||||||
.foregroundColor(selectedCategory != nil ? .accentColor : .primary)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear Filters
|
|
||||||
if selectedLevel != nil || selectedCategory != nil {
|
|
||||||
Button {
|
|
||||||
selectedLevel = nil
|
|
||||||
selectedCategory = nil
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
Text("Clear")
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color(.systemGray5))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var loggingDisabledBanner: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
.font(.title3)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Logging Disabled")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Text("Enable logging in settings to capture new logs")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
logConfig.isLoggingEnabled = true
|
|
||||||
} label: {
|
|
||||||
Text("Enable")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color.orange)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color.orange.opacity(0.1))
|
|
||||||
.cornerRadius(12)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var emptyState: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "doc.text.magnifyingglass")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Text("No Logs Found")
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
|
|
||||||
if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil {
|
|
||||||
Text("Try adjusting your filters or search criteria")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
searchText = ""
|
|
||||||
selectedLevel = nil
|
|
||||||
selectedCategory = nil
|
|
||||||
} label: {
|
|
||||||
Text("Clear Filters")
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("Logs will appear here as they are generated")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var filteredEntries: [LogEntry] {
|
|
||||||
var filtered = entries
|
|
||||||
|
|
||||||
if let level = selectedLevel {
|
|
||||||
filtered = filtered.filter { $0.level == level }
|
|
||||||
}
|
|
||||||
|
|
||||||
if let category = selectedCategory {
|
|
||||||
filtered = filtered.filter { $0.category == category }
|
|
||||||
}
|
|
||||||
|
|
||||||
if !searchText.isEmpty {
|
|
||||||
filtered = filtered.filter {
|
|
||||||
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
|
||||||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
|
||||||
$0.function.localizedCaseInsensitiveContains(searchText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshLogs() async {
|
|
||||||
entries = await LogStore.shared.getEntries()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearLogs() async {
|
|
||||||
await LogStore.shared.clear()
|
|
||||||
await refreshLogs()
|
|
||||||
logger.info("Cleared all debug logs")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func exportLogs() async {
|
|
||||||
exportText = await LogStore.shared.exportAsText()
|
|
||||||
showShareSheet = true
|
|
||||||
logger.info("Exported debug logs")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func levelName(for level: LogLevel) -> String {
|
|
||||||
switch level.rawValue {
|
|
||||||
case 0: return "Debug"
|
|
||||||
case 1: return "Info"
|
|
||||||
case 2: return "Notice"
|
|
||||||
case 3: return "Warning"
|
|
||||||
case 4: return "Error"
|
|
||||||
case 5: return "Critical"
|
|
||||||
default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Log Entry Row
|
|
||||||
|
|
||||||
struct LogEntryRow: View {
|
|
||||||
let entry: LogEntry
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack {
|
|
||||||
// Level Badge
|
|
||||||
Text(levelName(for: entry.level))
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(levelColor(for: entry.level).opacity(0.2))
|
|
||||||
.foregroundColor(levelColor(for: entry.level))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
|
|
||||||
// Category
|
|
||||||
Text(entry.category.rawValue)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Timestamp
|
|
||||||
Text(entry.formattedTimestamp)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message
|
|
||||||
Text(entry.message)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
// Source Location
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "doc.text")
|
|
||||||
.font(.caption2)
|
|
||||||
Text("\(entry.fileName):\(entry.line)")
|
|
||||||
.font(.caption)
|
|
||||||
Text("•")
|
|
||||||
.font(.caption)
|
|
||||||
Text(entry.function)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func levelName(for level: LogLevel) -> String {
|
|
||||||
switch level.rawValue {
|
|
||||||
case 0: return "DEBUG"
|
|
||||||
case 1: return "INFO"
|
|
||||||
case 2: return "NOTICE"
|
|
||||||
case 3: return "WARN"
|
|
||||||
case 4: return "ERROR"
|
|
||||||
case 5: return "CRITICAL"
|
|
||||||
default: return "UNKNOWN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func levelColor(for level: LogLevel) -> Color {
|
|
||||||
switch level.rawValue {
|
|
||||||
case 0: return .blue
|
|
||||||
case 1: return .green
|
|
||||||
case 2: return .cyan
|
|
||||||
case 3: return .orange
|
|
||||||
case 4: return .red
|
|
||||||
case 5: return .purple
|
|
||||||
default: return .gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Activity View (for Share Sheet)
|
|
||||||
|
|
||||||
struct ActivityView: UIViewControllerRepresentable {
|
|
||||||
let activityItems: [Any]
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
||||||
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NavigationStack {
|
|
||||||
DebugLogViewer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,54 +15,76 @@ struct FontSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
VStack(spacing: 20) {
|
||||||
Section {
|
SectionHeader(title: "Font Settings".localized, icon: "textformat")
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
// Font Family Picker
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||||
|
Text("Font family")
|
||||||
|
.font(.headline)
|
||||||
Picker("Font family", selection: $viewModel.selectedFontFamily) {
|
Picker("Font family", selection: $viewModel.selectedFontFamily) {
|
||||||
ForEach(FontFamily.allCases, id: \.self) { family in
|
ForEach(FontFamily.allCases, id: \.self) { family in
|
||||||
Text(family.displayName).tag(family)
|
Text(family.displayName).tag(family)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
.onChange(of: viewModel.selectedFontFamily) {
|
.onChange(of: viewModel.selectedFontFamily) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.saveFontSettings()
|
await viewModel.saveFontSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
|
||||||
ForEach(FontSize.allCases, id: \.self) { size in
|
|
||||||
Text(size.displayName).tag(size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.onChange(of: viewModel.selectedFontSize) {
|
|
||||||
Task {
|
|
||||||
await viewModel.saveFontSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Font Settings")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
VStack(spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("readeck Bookmark Title")
|
|
||||||
.font(viewModel.previewTitleFont)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
// Font Size Picker
|
||||||
.font(viewModel.previewBodyFont)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.lineLimit(3)
|
Text("Font size")
|
||||||
|
.font(.headline)
|
||||||
Text("12 min • Today • example.com")
|
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
||||||
.font(viewModel.previewCaptionFont)
|
ForEach(FontSize.allCases, id: \.self) { size in
|
||||||
.foregroundColor(.secondary)
|
Text(size.displayName).tag(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
.onChange(of: viewModel.selectedFontSize) {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font Preview
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Preview")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("readeck Bookmark Title")
|
||||||
|
.font(viewModel.previewTitleFont)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
||||||
|
.font(viewModel.previewBodyFont)
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
Text("12 min • Today • example.com")
|
||||||
|
.font(viewModel.previewCaptionFont)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
} header: {
|
|
||||||
Text("Preview")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadFontSettings()
|
await viewModel.loadFontSettings()
|
||||||
}
|
}
|
||||||
@ -70,10 +92,7 @@ struct FontSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
List {
|
FontSettingsView(viewModel: .init(
|
||||||
FontSettingsView(viewModel: .init(
|
factory: MockUseCaseFactory())
|
||||||
factory: MockUseCaseFactory())
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,65 +5,108 @@ struct LegalPrivacySettingsView: View {
|
|||||||
@State private var showingLegalNotice = false
|
@State private var showingLegalNotice = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
VStack(spacing: 20) {
|
||||||
Section {
|
SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Privacy Policy
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingPrivacyPolicy = true
|
showingPrivacyPolicy = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(NSLocalizedString("Privacy Policy", comment: ""))
|
Text(NSLocalizedString("Privacy Policy", comment: ""))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Legal Notice
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingLegalNotice = true
|
showingLegalNotice = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(NSLocalizedString("Legal Notice", comment: ""))
|
Text(NSLocalizedString("Legal Notice", comment: ""))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
} header: {
|
.buttonStyle(.plain)
|
||||||
Text("Legal & Privacy")
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Divider()
|
||||||
Button(action: {
|
.padding(.vertical, 8)
|
||||||
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Report an Issue", comment: ""))
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
// Support Section
|
||||||
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
VStack(spacing: 12) {
|
||||||
UIApplication.shared.open(url)
|
// Report an Issue
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text(NSLocalizedString("Report an Issue", comment: ""))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
}) {
|
.buttonStyle(.plain)
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Contact Support", comment: ""))
|
// Contact Support
|
||||||
Spacer()
|
Button(action: {
|
||||||
Image(systemName: "arrow.up.right")
|
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
||||||
.font(.caption)
|
UIApplication.shared.open(url)
|
||||||
.foregroundColor(.secondary)
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text(NSLocalizedString("Contact Support", comment: ""))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
} header: {
|
|
||||||
Text("Support")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingPrivacyPolicy) {
|
.sheet(isPresented: $showingPrivacyPolicy) {
|
||||||
@ -76,8 +119,7 @@ struct LegalPrivacySettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
List {
|
LegalPrivacySettingsView()
|
||||||
LegalPrivacySettingsView()
|
.cardStyle()
|
||||||
}
|
.padding()
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
}
|
||||||
@ -5,6 +5,8 @@
|
|||||||
// Created by Ilyas Hallak on 16.08.25.
|
// Created by Ilyas Hallak on 16.08.25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@ -13,70 +15,82 @@ struct LoggingConfigurationView: View {
|
|||||||
private let logger = Logger.ui
|
private let logger = Logger.ui
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
NavigationView {
|
||||||
Section {
|
Form {
|
||||||
Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled)
|
Section(header: Text("Global Settings")) {
|
||||||
.tint(.green)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
} header: {
|
Text("Global Minimum Level")
|
||||||
Text("Logging Status")
|
.font(.headline)
|
||||||
} footer: {
|
|
||||||
Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if logConfig.isLoggingEnabled {
|
Picker("Global Level", selection: $logConfig.globalMinLevel) {
|
||||||
Section {
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
NavigationLink {
|
HStack {
|
||||||
GlobalLogLevelView(logConfig: logConfig)
|
Text(level.emoji)
|
||||||
} label: {
|
Text(level.rawValue == 0 ? "Debug" :
|
||||||
HStack {
|
level.rawValue == 1 ? "Info" :
|
||||||
Label("Global Log Level", systemImage: "slider.horizontal.3")
|
level.rawValue == 2 ? "Notice" :
|
||||||
Spacer()
|
level.rawValue == 3 ? "Warning" :
|
||||||
Text(levelName(for: logConfig.globalMinLevel))
|
level.rawValue == 4 ? "Error" : "Critical")
|
||||||
.foregroundColor(.secondary)
|
}
|
||||||
|
.tag(level)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
|
||||||
|
Text("Logs below this level will be filtered out globally")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
|
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
|
||||||
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
|
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
|
||||||
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
|
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
|
||||||
} header: {
|
|
||||||
Text("Global Settings")
|
|
||||||
} footer: {
|
|
||||||
Text("Logs below the global level will be filtered out globally")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if logConfig.isLoggingEnabled {
|
Section(header: Text("Category-specific Levels")) {
|
||||||
Section {
|
|
||||||
ForEach(LogCategory.allCases, id: \.self) { category in
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||||
NavigationLink {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
CategoryLogLevelView(category: category, logConfig: logConfig)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(category.rawValue)
|
Text(category.rawValue)
|
||||||
|
.font(.headline)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(levelName(for: logConfig.getLevel(for: category)))
|
Text(levelName(for: logConfig.getLevel(for: category)))
|
||||||
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Category-specific Levels")
|
|
||||||
} footer: {
|
|
||||||
Text("Configure log levels for each category individually")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Picker("Level for \(category.rawValue)", selection: Binding(
|
||||||
Button(role: .destructive) {
|
get: { logConfig.getLevel(for: category) },
|
||||||
resetToDefaults()
|
set: { logConfig.setLevel($0, for: category) }
|
||||||
} label: {
|
)) {
|
||||||
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
HStack {
|
||||||
|
Text(level.emoji)
|
||||||
|
Text(levelName(for: level))
|
||||||
|
}
|
||||||
|
.tag(level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Reset")) {
|
||||||
|
Button("Reset to Defaults") {
|
||||||
|
resetToDefaults()
|
||||||
|
}
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) {
|
||||||
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Logging Configuration")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
.navigationTitle("Logging Configuration")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
logger.debug("Opened logging configuration view")
|
logger.debug("Opened logging configuration view")
|
||||||
}
|
}
|
||||||
@ -97,10 +111,12 @@ struct LoggingConfigurationView: View {
|
|||||||
private func resetToDefaults() {
|
private func resetToDefaults() {
|
||||||
logger.info("Resetting logging configuration to defaults")
|
logger.info("Resetting logging configuration to defaults")
|
||||||
|
|
||||||
|
// Reset all category levels (this will use globalMinLevel as fallback)
|
||||||
for category in LogCategory.allCases {
|
for category in LogCategory.allCases {
|
||||||
logConfig.setLevel(.debug, for: category)
|
logConfig.setLevel(.debug, for: category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset global settings
|
||||||
logConfig.globalMinLevel = .debug
|
logConfig.globalMinLevel = .debug
|
||||||
logConfig.showPerformanceLogs = true
|
logConfig.showPerformanceLogs = true
|
||||||
logConfig.showTimestamps = true
|
logConfig.showTimestamps = true
|
||||||
@ -110,123 +126,6 @@ struct LoggingConfigurationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Global Log Level View
|
|
||||||
|
|
||||||
struct GlobalLogLevelView: View {
|
|
||||||
@ObservedObject var logConfig: LogConfiguration
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
|
||||||
Button {
|
|
||||||
logConfig.globalMinLevel = level
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(levelName(for: level))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text(levelDescription(for: level))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if logConfig.globalMinLevel == level {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Global Log Level")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func levelName(for level: LogLevel) -> String {
|
|
||||||
switch level.rawValue {
|
|
||||||
case 0: return "Debug"
|
|
||||||
case 1: return "Info"
|
|
||||||
case 2: return "Notice"
|
|
||||||
case 3: return "Warning"
|
|
||||||
case 4: return "Error"
|
|
||||||
case 5: return "Critical"
|
|
||||||
default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func levelDescription(for level: LogLevel) -> String {
|
|
||||||
switch level.rawValue {
|
|
||||||
case 0: return "Show all logs including debug information"
|
|
||||||
case 1: return "Show informational messages and above"
|
|
||||||
case 2: return "Show notable events and above"
|
|
||||||
case 3: return "Show warnings and errors only"
|
|
||||||
case 4: return "Show errors and critical issues only"
|
|
||||||
case 5: return "Show only critical issues"
|
|
||||||
default: return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Category Log Level View
|
|
||||||
|
|
||||||
struct CategoryLogLevelView: View {
|
|
||||||
let category: LogCategory
|
|
||||||
@ObservedObject var logConfig: LogConfiguration
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
|
||||||
Button {
|
|
||||||
logConfig.setLevel(level, for: category)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(levelName(for: level))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text(levelDescription(for: level))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if logConfig.getLevel(for: category) == level {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("\(category.rawValue) Logs")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func levelName(for level: LogLevel) -> String {
|
|
||||||
switch level.rawValue {
|
|
||||||
case 0: return "Debug"
|
|
||||||
case 1: return "Info"
|
|
||||||
case 2: return "Notice"
|
|
||||||
case 3: return "Warning"
|
|
||||||
case 4: return "Error"
|
|
||||||
case 5: return "Critical"
|
|
||||||
default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func levelDescription(for level: LogLevel) -> String {
|
|
||||||
switch level.rawValue {
|
|
||||||
case 0: return "Show all logs including debug information"
|
|
||||||
case 1: return "Show informational messages and above"
|
|
||||||
case 2: return "Show notable events and above"
|
|
||||||
case 3: return "Show warnings and errors only"
|
|
||||||
case 4: return "Show errors and critical issues only"
|
|
||||||
case 5: return "Show only critical issues"
|
|
||||||
default: return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
LoggingConfigurationView()
|
||||||
LoggingConfigurationView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import MarkdownUI
|
|
||||||
|
|
||||||
/// A custom view that renders Markdown content using the MarkdownUI library.
|
|
||||||
/// This view encapsulates the Markdown rendering logic, making it easy to swap
|
|
||||||
/// the underlying Markdown library if needed in the future.
|
|
||||||
struct MarkdownContentView: View {
|
|
||||||
let content: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Markdown(content)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ScrollView {
|
|
||||||
MarkdownContentView(content: """
|
|
||||||
# Heading 1
|
|
||||||
|
|
||||||
This is a paragraph with **bold** and *italic* text.
|
|
||||||
|
|
||||||
## Heading 2
|
|
||||||
|
|
||||||
- List item 1
|
|
||||||
- List item 2
|
|
||||||
- List item 3
|
|
||||||
|
|
||||||
### Heading 3
|
|
||||||
|
|
||||||
Another paragraph with [a link](https://example.com).
|
|
||||||
""")
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +1,56 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
extension AttributedString {
|
||||||
|
init(styledMarkdown markdownString: String) throws {
|
||||||
|
var output = try AttributedString(
|
||||||
|
markdown: markdownString,
|
||||||
|
options: .init(
|
||||||
|
allowsExtendedAttributes: true,
|
||||||
|
interpretedSyntax: .full,
|
||||||
|
failurePolicy: .returnPartiallyParsedIfPossible
|
||||||
|
),
|
||||||
|
baseURL: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
|
||||||
|
guard let intentBlock = intentBlock else { continue }
|
||||||
|
for intent in intentBlock.components {
|
||||||
|
switch intent.kind {
|
||||||
|
case .header(level: let level):
|
||||||
|
switch level {
|
||||||
|
case 1:
|
||||||
|
output[intentRange].font = .system(.title).bold()
|
||||||
|
case 2:
|
||||||
|
output[intentRange].font = .system(.title2).bold()
|
||||||
|
case 3:
|
||||||
|
output[intentRange].font = .system(.title3).bold()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if intentRange.lowerBound != output.startIndex {
|
||||||
|
output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self = output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ReleaseNotesView: View {
|
struct ReleaseNotesView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if let markdownContent = loadReleaseNotes() {
|
if let attributedString = loadReleaseNotes() {
|
||||||
MarkdownContentView(content: markdownContent)
|
Text(attributedString)
|
||||||
|
.textSelection(.enabled)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
Text("Unable to load release notes")
|
Text("Unable to load release notes")
|
||||||
@ -29,12 +71,13 @@ struct ReleaseNotesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadReleaseNotes() -> String? {
|
private func loadReleaseNotes() -> AttributedString? {
|
||||||
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
||||||
let markdownContent = try? String(contentsOf: url) else {
|
let markdownContent = try? String(contentsOf: url),
|
||||||
|
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return markdownContent
|
return attributedString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,82 +16,103 @@ struct SettingsContainerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
ScrollView {
|
||||||
AppearanceSettingsView()
|
LazyVStack(spacing: 20) {
|
||||||
|
FontSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
CacheSettingsView()
|
AppearanceSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
SettingsGeneralView()
|
CacheSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
SettingsServerView()
|
SettingsGeneralView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
LegalPrivacySettingsView()
|
SettingsServerView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
// Debug-only Logging Configuration
|
LegalPrivacySettingsView()
|
||||||
#if DEBUG
|
.cardStyle()
|
||||||
if Bundle.main.isDebugBuild {
|
|
||||||
debugSettingsSection
|
// Debug-only Logging Configuration
|
||||||
|
if Bundle.main.isDebugBuild {
|
||||||
|
debugSettingsSection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
.padding()
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
|
||||||
// App Info Section
|
AppInfo()
|
||||||
appInfoSection
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.background(Color(.systemGroupedBackground))
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var debugSettingsSection: some View {
|
private var debugSettingsSection: some View {
|
||||||
Section {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SettingsRowNavigationLink(
|
|
||||||
icon: "list.bullet.rectangle",
|
|
||||||
iconColor: .blue,
|
|
||||||
title: "Debug Logs",
|
|
||||||
subtitle: "View all debug messages"
|
|
||||||
) {
|
|
||||||
DebugLogViewer()
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsRowNavigationLink(
|
|
||||||
icon: "slider.horizontal.3",
|
|
||||||
iconColor: .purple,
|
|
||||||
title: "Logging Configuration",
|
|
||||||
subtitle: "Configure log levels and categories"
|
|
||||||
) {
|
|
||||||
LoggingConfigurationView()
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
HStack {
|
HStack {
|
||||||
|
Image(systemName: "ant.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
Text("Debug Settings")
|
Text("Debug Settings")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("DEBUG BUILD")
|
Text("DEBUG BUILD")
|
||||||
.font(.caption2)
|
.font(.caption)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 4)
|
||||||
.background(Color.orange.opacity(0.2))
|
.background(Color.orange.opacity(0.2))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
LoggingConfigurationView()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "doc.text.magnifyingglass")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Logging Configuration")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("Configure log levels and categories")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.cardStyle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var appInfoSection: some View {
|
func AppInfo() -> some View {
|
||||||
Section {
|
VStack(spacing: 4) {
|
||||||
VStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
Image(systemName: "info.circle")
|
||||||
Image(systemName: "info.circle")
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.secondary)
|
Text("Version \(appVersion)")
|
||||||
Text("Version \(appVersion)")
|
.font(.footnote)
|
||||||
.font(.footnote)
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.secondary)
|
}
|
||||||
}
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "person.crop.circle")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "person.crop.circle")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("Developer:")
|
Text("Developer:")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@ -102,23 +123,26 @@ struct SettingsContainerView: View {
|
|||||||
}
|
}
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
}
|
.underline()
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("From Bremen with 💚")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
HStack(spacing: 8) {
|
||||||
.padding(.vertical, 8)
|
Image(systemName: "globe")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("From Bremen with 💚")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.opacity(0.7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card Modifier für einheitlichen Look (kept for backwards compatibility with other views)
|
// Card Modifier für einheitlichen Look
|
||||||
extension View {
|
extension View {
|
||||||
func cardStyle() -> some View {
|
func cardStyle() -> some View {
|
||||||
self
|
self
|
||||||
@ -130,7 +154,5 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
SettingsContainerView()
|
||||||
SettingsContainerView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,15 @@ struct SettingsGeneralView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
VStack(spacing: 20) {
|
||||||
Section {
|
SectionHeader(title: "General Settings".localized, icon: "gear")
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("General")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
// What's New Button
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showReleaseNotes = true
|
showReleaseNotes = true
|
||||||
}) {
|
}) {
|
||||||
@ -32,57 +39,83 @@ struct SettingsGeneralView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
||||||
|
.toggleStyle(.switch)
|
||||||
.onChange(of: viewModel.enableTTS) {
|
.onChange(of: viewModel.enableTTS) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.saveGeneralSettings()
|
await viewModel.saveGeneralSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
|
||||||
Text("General")
|
|
||||||
} footer: {
|
|
||||||
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
|
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading Settings
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Open external links in".localized)
|
||||||
|
.font(.headline)
|
||||||
|
Picker("urlOpener", selection: $viewModel.urlOpener) {
|
||||||
|
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
|
||||||
|
Text(urlOpener.displayName.localized).tag(urlOpener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.urlOpener) {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveGeneralSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Section {
|
// Sync Settings
|
||||||
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
if viewModel.autoSyncEnabled {
|
|
||||||
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Sync Settings")
|
Text("Sync Settings")
|
||||||
}
|
.font(.headline)
|
||||||
|
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
||||||
Section {
|
.toggleStyle(SwitchToggleStyle())
|
||||||
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
if viewModel.autoSyncEnabled {
|
||||||
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
|
||||||
} header: {
|
|
||||||
Text("Reading Settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let successMessage = viewModel.successMessage {
|
|
||||||
Section {
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Text("Sync interval")
|
||||||
.foregroundColor(.green)
|
Spacer()
|
||||||
Text(successMessage)
|
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
||||||
.foregroundColor(.green)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading Settings
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Reading Settings")
|
||||||
|
.font(.headline)
|
||||||
|
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||||
|
.toggleStyle(SwitchToggleStyle())
|
||||||
|
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||||
|
.toggleStyle(SwitchToggleStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text(successMessage)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
Section {
|
HStack {
|
||||||
HStack {
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
.foregroundColor(.red)
|
||||||
.foregroundColor(.red)
|
Text(errorMessage)
|
||||||
Text(errorMessage)
|
.foregroundColor(.red)
|
||||||
.foregroundColor(.red)
|
.font(.caption)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showReleaseNotes) {
|
.sheet(isPresented: $showReleaseNotes) {
|
||||||
ReleaseNotesView()
|
ReleaseNotesView()
|
||||||
@ -94,10 +127,7 @@ struct SettingsGeneralView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
List {
|
SettingsGeneralView(viewModel: .init(
|
||||||
SettingsGeneralView(viewModel: .init(
|
MockUseCaseFactory()
|
||||||
MockUseCaseFactory()
|
))
|
||||||
))
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,33 +11,189 @@ struct SettingsServerView: View {
|
|||||||
@State private var viewModel = SettingsServerViewModel()
|
@State private var viewModel = SettingsServerViewModel()
|
||||||
@State private var showingLogoutAlert = false
|
@State private var showingLogoutAlert = false
|
||||||
|
|
||||||
|
init(showingLogoutAlert: Bool = false) {
|
||||||
|
self.showingLogoutAlert = showingLogoutAlert
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
VStack(spacing: 20) {
|
||||||
SettingsRowValue(
|
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
|
||||||
icon: "server.rack",
|
.padding(.bottom, 4)
|
||||||
title: "Server",
|
|
||||||
value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint
|
|
||||||
)
|
|
||||||
|
|
||||||
SettingsRowValue(
|
Text(viewModel.isSetupMode ?
|
||||||
icon: "person.circle.fill",
|
"Enter your Readeck server details to get started." :
|
||||||
title: "Username",
|
"Your current server connection and login credentials.")
|
||||||
value: viewModel.username.isEmpty ? "Not set" : viewModel.username
|
.font(.body)
|
||||||
)
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
SettingsRowButton(
|
// Form
|
||||||
icon: "rectangle.portrait.and.arrow.right",
|
VStack(spacing: 16) {
|
||||||
iconColor: .red,
|
// Server Endpoint
|
||||||
title: "Logout",
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
subtitle: nil,
|
if viewModel.isSetupMode {
|
||||||
destructive: true
|
TextField("",
|
||||||
) {
|
text: $viewModel.endpoint,
|
||||||
showingLogoutAlert = true
|
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.onChange(of: viewModel.endpoint) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Input Chips
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
QuickInputChip(text: "http://", action: {
|
||||||
|
if !viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint = "http://" + viewModel.endpoint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: "https://", action: {
|
||||||
|
if !viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint = "https://" + viewModel.endpoint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: "192.168.", action: {
|
||||||
|
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
|
||||||
|
if viewModel.endpoint.starts(with: "http") {
|
||||||
|
viewModel.endpoint += "192.168."
|
||||||
|
} else {
|
||||||
|
viewModel.endpoint = "http://192.168."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
QuickInputChip(text: ":8000", action: {
|
||||||
|
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
|
||||||
|
viewModel.endpoint += ":8000"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "server.rack")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
Text(viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint)
|
||||||
|
.foregroundColor(viewModel.endpoint.isEmpty ? .secondary : .primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if viewModel.isSetupMode {
|
||||||
|
TextField("",
|
||||||
|
text: $viewModel.username,
|
||||||
|
prompt: Text("Username").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.onChange(of: viewModel.username) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
Text(viewModel.username.isEmpty ? "Not set" : viewModel.username)
|
||||||
|
.foregroundColor(viewModel.username.isEmpty ? .secondary : .primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password
|
||||||
|
if viewModel.isSetupMode {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
SecureField("",
|
||||||
|
text: $viewModel.password,
|
||||||
|
prompt: Text("Password").foregroundColor(.secondary))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onChange(of: viewModel.password) {
|
||||||
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text(successMessage)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isSetupMode {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button(action: {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveServerSettings()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
}
|
||||||
|
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
showingLogoutAlert = true
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||||
|
.font(.caption)
|
||||||
|
Text("Logout")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(.systemGray5))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
|
||||||
Text("Server Connection")
|
|
||||||
} footer: {
|
|
||||||
Text("Your current server connection and login credentials.")
|
|
||||||
}
|
}
|
||||||
.alert("Logout", isPresented: $showingLogoutAlert) {
|
.alert("Logout", isPresented: $showingLogoutAlert) {
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
@ -55,9 +211,22 @@ struct SettingsServerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
// MARK: - Quick Input Chip Component
|
||||||
List {
|
|
||||||
SettingsServerView()
|
struct QuickInputChip: View {
|
||||||
|
let text: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.systemGray5))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,12 +44,4 @@ struct URLUtil {
|
|||||||
guard let url = URL(string: urlString), let host = url.host else { return nil }
|
guard let url = URL(string: urlString), let host = url.host else { return nil }
|
||||||
return host.replacingOccurrences(of: "www.", with: "")
|
return host.replacingOccurrences(of: "www.", with: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func openUrlLabel(for urlString: String) -> String {
|
|
||||||
if let domain = extractDomain(from: urlString) {
|
|
||||||
return String(format: "open_url".localized, domain)
|
|
||||||
} else {
|
|
||||||
return "open_original_page".localized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ struct readeckApp: App {
|
|||||||
if appViewModel.hasFinishedSetup {
|
if appViewModel.hasFinishedSetup {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
} else {
|
} else {
|
||||||
OnboardingServerView()
|
SettingsServerView()
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,145 +0,0 @@
|
|||||||
//
|
|
||||||
// LogStore.swift
|
|
||||||
// readeck
|
|
||||||
//
|
|
||||||
// Created by Ilyas Hallak on 01.11.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - Log Entry
|
|
||||||
|
|
||||||
struct LogEntry: Identifiable, Codable {
|
|
||||||
let id: UUID
|
|
||||||
let timestamp: Date
|
|
||||||
let level: LogLevel
|
|
||||||
let category: LogCategory
|
|
||||||
let message: String
|
|
||||||
let file: String
|
|
||||||
let function: String
|
|
||||||
let line: Int
|
|
||||||
|
|
||||||
var fileName: String {
|
|
||||||
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
var formattedTimestamp: String {
|
|
||||||
DateFormatter.logTimestamp.string(from: timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: UUID = UUID(),
|
|
||||||
timestamp: Date = Date(),
|
|
||||||
level: LogLevel,
|
|
||||||
category: LogCategory,
|
|
||||||
message: String,
|
|
||||||
file: String,
|
|
||||||
function: String,
|
|
||||||
line: Int
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.level = level
|
|
||||||
self.category = category
|
|
||||||
self.message = message
|
|
||||||
self.file = file
|
|
||||||
self.function = function
|
|
||||||
self.line = line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Log Store
|
|
||||||
|
|
||||||
actor LogStore {
|
|
||||||
static let shared = LogStore()
|
|
||||||
|
|
||||||
private var entries: [LogEntry] = []
|
|
||||||
private let maxEntries: Int
|
|
||||||
|
|
||||||
private init(maxEntries: Int = 1000) {
|
|
||||||
self.maxEntries = maxEntries
|
|
||||||
}
|
|
||||||
|
|
||||||
func addEntry(_ entry: LogEntry) {
|
|
||||||
entries.append(entry)
|
|
||||||
|
|
||||||
// Keep only the most recent entries
|
|
||||||
if entries.count > maxEntries {
|
|
||||||
entries.removeFirst(entries.count - maxEntries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEntries() -> [LogEntry] {
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEntries(
|
|
||||||
level: LogLevel? = nil,
|
|
||||||
category: LogCategory? = nil,
|
|
||||||
searchText: String? = nil
|
|
||||||
) -> [LogEntry] {
|
|
||||||
var filtered = entries
|
|
||||||
|
|
||||||
if let level = level {
|
|
||||||
filtered = filtered.filter { $0.level == level }
|
|
||||||
}
|
|
||||||
|
|
||||||
if let category = category {
|
|
||||||
filtered = filtered.filter { $0.category == category }
|
|
||||||
}
|
|
||||||
|
|
||||||
if let searchText = searchText, !searchText.isEmpty {
|
|
||||||
filtered = filtered.filter {
|
|
||||||
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
|
||||||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
|
||||||
$0.function.localizedCaseInsensitiveContains(searchText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func clear() {
|
|
||||||
entries.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportAsText() -> String {
|
|
||||||
var text = "Readeck Debug Logs\n"
|
|
||||||
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
|
|
||||||
text += "Total Entries: \(entries.count)\n"
|
|
||||||
text += String(repeating: "=", count: 80) + "\n\n"
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
text += "[\(entry.formattedTimestamp)] "
|
|
||||||
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
|
|
||||||
text += "[\(entry.category.rawValue)] "
|
|
||||||
text += "\(entry.fileName):\(entry.line) "
|
|
||||||
text += "\(entry.function)\n"
|
|
||||||
text += " \(entry.message)\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
private func levelName(for level: LogLevel) -> String {
|
|
||||||
switch level.rawValue {
|
|
||||||
case 0: return "DEBUG"
|
|
||||||
case 1: return "INFO"
|
|
||||||
case 2: return "NOTICE"
|
|
||||||
case 3: return "WARNING"
|
|
||||||
case 4: return "ERROR"
|
|
||||||
case 5: return "CRITICAL"
|
|
||||||
default: return "UNKNOWN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - DateFormatter Extension
|
|
||||||
|
|
||||||
extension DateFormatter {
|
|
||||||
static let exportTimestamp: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user