feat: Add bookmark creation functionality and WebView dark mode support

- Add CREATE API endpoint for new bookmarks with POST request
- Implement CreateBookmarkRequestDto and CreateBookmarkResponseDto
- Create AddBookmarkView with form validation and clipboard integration
- Add AddBookmarkViewModel with URL validation and label parsing
- Implement CreateBookmarkUseCase with convenience methods
- Extend BookmarksRepository with createBookmark method returning server message
- Add comprehensive error handling for bookmark creation scenarios
- Integrate WebView dark mode support with CSS variables and system color scheme
- Add dynamic theme switching based on iOS appearance settings
- Enhance WebView styling with iOS-native colors and typography
- Fix BookmarksView refresh after bookmark creation
- Add floating action button and sheet presentation for adding bookmarks
- Implement form validation with real-time feedback
- Add clipboard URL detection and paste functionality
This commit is contained in:
Ilyas Hallak 2025-06-11 23:02:58 +02:00
parent cd265730d3
commit da7bb1613c
14 changed files with 520 additions and 30 deletions

View File

@ -13,6 +13,7 @@ protocol PAPI {
func getBookmarks(state: BookmarkState?) async throws -> [BookmarkDto]
func getBookmark(id: String) async throws -> BookmarkDetailDto
func getBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws
func deleteBookmark(id: String) async throws
}
@ -165,6 +166,17 @@ class API: PAPI {
)
}
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto {
let requestData = try JSONEncoder().encode(createRequest)
return try await makeJSONRequest(
endpoint: "/api/bookmarks",
method: .POST,
body: requestData,
responseType: CreateBookmarkResponseDto.self
)
}
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws {
let requestData = try JSONEncoder().encode(updateRequest)

View File

@ -13,7 +13,7 @@ struct BookmarkDto: Codable {
let siteName: String
let site: String
let readingTime: Int?
let wordCount: Int
let wordCount: Int?
let hasArticle: Bool
let isArchived: Bool
let isDeleted: Bool
@ -62,3 +62,5 @@ struct ImageResourceDto: Codable {
let height: Int
let width: Int
}

View File

@ -0,0 +1,13 @@
import Foundation
struct CreateBookmarkRequestDto: Codable {
let labels: [String]?
let title: String?
let url: String
init(url: String, title: String? = nil, labels: [String]? = nil) {
self.url = url
self.title = title
self.labels = labels
}
}

View File

@ -0,0 +1,6 @@
import Foundation
struct CreateBookmarkResponseDto: Codable {
let message: String
let status: Int
}

View File

@ -4,9 +4,9 @@ protocol PBookmarksRepository {
func fetchBookmarks(state: BookmarkState?) async throws -> [Bookmark]
func fetchBookmark(id: String) async throws -> BookmarkDetail
func fetchBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
func deleteBookmark(id: String) async throws
func addBookmark(bookmark: Bookmark) async throws
}
class BookmarksRepository: PBookmarksRepository {
@ -46,8 +46,21 @@ class BookmarksRepository: PBookmarksRepository {
return try await api.getBookmarkArticle(id: id)
}
func addBookmark(bookmark: Bookmark) async throws {
// Implement logic to add a bookmark if needed
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String {
let dto = CreateBookmarkRequestDto(
url: createRequest.url,
title: createRequest.title,
labels: createRequest.labels
)
let response = try await api.createBookmark(createRequest: dto)
// Prüfe ob die Erstellung erfolgreich war
guard response.status == 0 else {
throw CreateBookmarkError.serverError(response.message)
}
return response.message
}
func deleteBookmark(id: String) async throws {
@ -80,7 +93,7 @@ struct BookmarkDetail {
let authors: [String]
let created: String
let updated: String
let wordCount: Int
let wordCount: Int?
let readingTime: Int?
let hasArticle: Bool
let isMarked: Bool
@ -88,3 +101,23 @@ struct BookmarkDetail {
let thumbnailUrl: String
let imageUrl: String
}
enum CreateBookmarkError: Error, LocalizedError {
case invalidURL
case duplicateBookmark
case networkError
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Die eingegebene URL ist ungültig"
case .duplicateBookmark:
return "Dieser Bookmark existiert bereits"
case .networkError:
return "Netzwerkfehler beim Erstellen des Bookmarks"
case .serverError(let message):
return message // Verwende die Server-Nachricht direkt
}
}
}

View File

@ -9,6 +9,7 @@ protocol UseCaseFactory {
func makeLoadSettingsUseCase() -> LoadSettingsUseCase
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
@ -57,4 +58,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase {
return DeleteBookmarkUseCase(repository: bookmarksRepository)
}
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase {
return CreateBookmarkUseCase(repository: bookmarksRepository)
}
}

View File

@ -13,7 +13,7 @@ struct Bookmark {
let siteName: String
let site: String
let readingTime: Int?
let wordCount: Int
let wordCount: Int?
let hasArticle: Bool
let isArchived: Bool
let isDeleted: Bool

View File

@ -0,0 +1,28 @@
import Foundation
struct CreateBookmarkRequest {
let url: String
let title: String?
let labels: [String]?
init(url: String, title: String? = nil, labels: [String]? = nil) {
self.url = url
self.title = title
self.labels = labels
}
}
// Convenience Initializers
extension CreateBookmarkRequest {
static func fromURL(_ url: String) -> CreateBookmarkRequest {
return CreateBookmarkRequest(url: url)
}
static func fromURLWithTitle(_ url: String, title: String) -> CreateBookmarkRequest {
return CreateBookmarkRequest(url: url, title: title)
}
static func fromURLWithLabels(_ url: String, labels: [String]) -> CreateBookmarkRequest {
return CreateBookmarkRequest(url: url, labels: labels)
}
}

View File

@ -0,0 +1,51 @@
import Foundation
class CreateBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {
self.repository = repository
}
func execute(createRequest: CreateBookmarkRequest) async throws -> String {
// URL-Validierung
guard URL(string: createRequest.url) != nil else {
throw CreateBookmarkError.invalidURL
}
return try await repository.createBookmark(createRequest: createRequest)
}
// Convenience methods für häufige Use Cases
func createFromURL(_ url: String) async throws -> String {
let request = CreateBookmarkRequest.fromURL(url)
return try await execute(createRequest: request)
}
func createFromURLWithTitle(_ url: String, title: String) async throws -> String {
let request = CreateBookmarkRequest.fromURLWithTitle(url, title: title)
return try await execute(createRequest: request)
}
func createFromURLWithLabels(_ url: String, labels: [String]) async throws -> String {
let request = CreateBookmarkRequest.fromURLWithLabels(url, labels: labels)
return try await execute(createRequest: request)
}
func createFromClipboard() async throws -> String? {
return nil
// URL aus Zwischenablage holen (falls verfügbar)
/*#if canImport(UIKit)
import UIKit
guard let clipboardString = UIPasteboard.general.string,
URL(string: clipboardString) != nil else {
return nil
}
let request = CreateBookmarkRequest.fromURL(clipboardString)
return try await execute(createRequest: request)
#else
return nil
#endif*/
}
}

View File

@ -0,0 +1,134 @@
import SwiftUI
struct AddBookmarkView: View {
@State private var viewModel = AddBookmarkViewModel()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
Form {
Section(header: Text("Bookmark Details")) {
VStack(alignment: .leading, spacing: 8) {
Text("URL *")
.font(.caption)
.foregroundColor(.secondary)
TextField("https://example.com", text: $viewModel.url)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.URL)
.autocapitalization(.none)
.autocorrectionDisabled()
}
VStack(alignment: .leading, spacing: 8) {
Text("Titel (optional)")
.font(.caption)
.foregroundColor(.secondary)
TextField("Bookmark Titel", text: $viewModel.title)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
Section(header: Text("Labels")) {
VStack(alignment: .leading, spacing: 8) {
Text("Labels (durch Komma getrennt)")
.font(.caption)
.foregroundColor(.secondary)
TextField("work, important, later", text: $viewModel.labelsText)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
if !viewModel.parsedLabels.isEmpty {
LazyVGrid(columns: [
GridItem(.adaptive(minimum: 80))
], spacing: 8) {
ForEach(viewModel.parsedLabels, id: \.self) { label in
Text(label)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.foregroundColor(.blue)
.clipShape(Capsule())
}
}
}
}
Section {
Button("Aus Zwischenablage einfügen") {
viewModel.pasteFromClipboard()
}
.disabled(viewModel.clipboardURL == nil)
if let clipboardURL = viewModel.clipboardURL {
Text("Zwischenablage: \(clipboardURL)")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
.navigationTitle("Bookmark hinzufügen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Speichern") {
Task {
await viewModel.createBookmark()
}
}
.disabled(!viewModel.isValid || viewModel.isLoading)
}
}
.overlay {
if viewModel.isLoading {
ZStack {
Color.black.opacity(0.3)
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.2)
Text("Bookmark wird erstellt...")
.font(.subheadline)
}
.padding(24)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
}
.ignoresSafeArea()
}
}
.alert("Erfolgreich", isPresented: $viewModel.showSuccessAlert) {
Button("OK") {
dismiss()
}
} message: {
Text("Bookmark wurde erfolgreich hinzugefügt!")
}
.alert("Fehler", isPresented: $viewModel.showErrorAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(viewModel.errorMessage ?? "Unbekannter Fehler")
}
}
.onAppear {
viewModel.checkClipboard()
}
}
}
#Preview {
AddBookmarkView()
}

View File

@ -0,0 +1,80 @@
import Foundation
import UIKit
@Observable
class AddBookmarkViewModel {
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
var url: String = ""
var title: String = ""
var labelsText: String = ""
var isLoading: Bool = false
var errorMessage: String?
var showErrorAlert: Bool = false
var showSuccessAlert: Bool = false
var clipboardURL: String?
var isValid: Bool {
!url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)) != nil
}
var parsedLabels: [String] {
labelsText
.components(separatedBy: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
@MainActor
func createBookmark() async {
guard isValid else { return }
isLoading = true
errorMessage = nil
do {
let cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let labels = parsedLabels
let request = CreateBookmarkRequest(
url: cleanURL,
title: cleanTitle.isEmpty ? nil : cleanTitle,
labels: labels.isEmpty ? nil : labels
)
let message = try await createBookmarkUseCase.execute(createRequest: request)
// Optional: Zeige die Server-Nachricht an
print("Server response: \(message)")
showSuccessAlert = true
} catch let error as CreateBookmarkError {
errorMessage = error.localizedDescription
showErrorAlert = true
} catch {
errorMessage = "Fehler beim Erstellen des Bookmarks"
showErrorAlert = true
}
isLoading = false
}
func checkClipboard() {
guard let clipboardString = UIPasteboard.general.string,
URL(string: clipboardString) != nil else {
clipboardURL = nil
return
}
clipboardURL = clipboardString
}
func pasteFromClipboard() {
guard let clipboardURL = clipboardURL else { return }
url = clipboardURL
}
}

View File

@ -28,17 +28,17 @@ struct BookmarkCardView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
// Status-Badges und Action-Button
// Status-Icons und Action-Button
HStack {
HStack(spacing: 4) {
HStack(spacing: 6) {
if bookmark.isMarked {
Badge(text: "Markiert", color: .blue)
IconBadge(systemName: "heart.fill", color: .red)
}
if bookmark.isArchived {
Badge(text: "Archiviert", color: .gray)
IconBadge(systemName: "archivebox.fill", color: .gray)
}
if bookmark.hasArticle {
Badge(text: "Artikel", color: .green)
IconBadge(systemName: "doc.text.fill", color: .green)
}
}
@ -146,18 +146,17 @@ struct BookmarkCardView: View {
}
}
struct Badge: View {
let text: String
struct IconBadge: View {
let systemName: String
let color: Color
var body: some View {
Text(text)
Image(systemName: systemName)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.padding(6)
.background(color.opacity(0.2))
.foregroundColor(color)
.clipShape(Capsule())
.clipShape(Circle())
}
}

View File

@ -2,6 +2,7 @@ import SwiftUI
struct BookmarksView: View {
@State private var viewModel = BookmarksViewModel()
@State private var showingAddBookmark = false
let state: BookmarkState
var body: some View {
@ -55,6 +56,18 @@ struct BookmarksView: View {
}
}
.navigationTitle(state.displayName)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingAddBookmark = true
}) {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView()
}
.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK", role: .cancel) {
viewModel.errorMessage = nil
@ -65,6 +78,14 @@ struct BookmarksView: View {
.task {
await viewModel.loadBookmarks(state: state)
}
.onChange(of: showingAddBookmark) { oldValue, newValue in
// Refresh bookmarks when sheet is dismissed
if oldValue && !newValue {
Task {
await viewModel.refreshBookmarks()
}
}
}
}
}
}

View File

@ -4,11 +4,14 @@ import WebKit
struct WebView: UIViewRepresentable {
let htmlContent: String
let onHeightChange: (CGFloat) -> Void
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
webView.scrollView.isScrollEnabled = false
webView.isOpaque = false
webView.backgroundColor = UIColor.clear
// Message Handler hier einmalig hinzufügen
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
@ -21,21 +24,39 @@ struct WebView: UIViewRepresentable {
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
context.coordinator.onHeightChange = onHeightChange
let isDarkMode = colorScheme == .dark
let styledHTML = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
<style>
:root {
--background-color: \(isDarkMode ? "#000000" : "#ffffff");
--text-color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
--heading-color: \(isDarkMode ? "#ffffff" : "#000000");
--link-color: \(isDarkMode ? "#0A84FF" : "#007AFF");
--quote-color: \(isDarkMode ? "#8E8E93" : "#666666");
--quote-border: \(isDarkMode ? "#0A84FF" : "#007AFF");
--code-background: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
--code-text: \(isDarkMode ? "#ffffff" : "#000000");
--separator-color: \(isDarkMode ? "#38383A" : "#e0e0e0");
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.8;
margin: 0;
padding: 16px;
color: #1a1a1a;
background-color: var(--background-color);
color: var(--text-color);
font-size: 16px;
-webkit-text-size-adjust: 100%;
}
h1, h2, h3, h4, h5, h6 {
color: #000;
color: var(--heading-color);
margin-top: 24px;
margin-bottom: 12px;
font-weight: 600;
@ -43,43 +64,123 @@ struct WebView: UIViewRepresentable {
h1 { font-size: 24px; }
h2 { font-size: 20px; }
h3 { font-size: 18px; }
p {
margin-bottom: 16px;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 16px 0;
}
a {
color: #007AFF;
color: var(--link-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
blockquote {
border-left: 4px solid #007AFF;
border-left: 4px solid var(--quote-border);
margin: 16px 0;
padding-left: 16px;
font-style: italic;
color: #666;
}
code {
background-color: #f5f5f5;
padding: 2px 4px;
color: var(--quote-color);
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)");
border-radius: 4px;
font-family: 'SF Mono', Consolas, monospace;
padding: 12px 16px;
}
code {
background-color: var(--code-background);
color: var(--code-text);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 14px;
}
pre {
background-color: #f5f5f5;
padding: 12px;
background-color: var(--code-background);
color: var(--code-text);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-family: 'SF Mono', Consolas, monospace;
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 14px;
border: 1px solid var(--separator-color);
}
pre code {
background-color: transparent;
padding: 0;
}
hr {
border: none;
height: 1px;
background-color: var(--separator-color);
margin: 24px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
th, td {
border: 1px solid var(--separator-color);
padding: 8px 12px;
text-align: left;
}
th {
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.5)" : "rgba(0, 0, 0, 0.05)");
font-weight: 600;
}
ul, ol {
padding-left: 20px;
margin-bottom: 16px;
}
li {
margin-bottom: 4px;
}
/* Dark mode media query als Fallback */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #000000;
--text-color: #ffffff;
--heading-color: #ffffff;
--link-color: #0A84FF;
--quote-color: #8E8E93;
--quote-border: #0A84FF;
--code-background: #1C1C1E;
--code-text: #ffffff;
--separator-color: #38383A;
}
}
/* Light mode media query als Fallback */
@media (prefers-color-scheme: light) {
:root {
--background-color: #ffffff;
--text-color: #1a1a1a;
--heading-color: #000000;
--link-color: #007AFF;
--quote-color: #666666;
--quote-border: #007AFF;
--code-background: #f5f5f5;
--code-text: #000000;
--separator-color: #e0e0e0;
}
}
</style>
</head>
@ -95,6 +196,11 @@ struct WebView: UIViewRepresentable {
setTimeout(updateHeight, 100);
setTimeout(updateHeight, 500);
setTimeout(updateHeight, 1000);
// Höhe bei Bild-Ladevorgängen aktualisieren
document.querySelectorAll('img').forEach(img => {
img.addEventListener('load', updateHeight);
});
</script>
</body>
</html>