Add bookmark labels functionality

- Add BookmarkLabel model and DTO
- Create LabelsRepository and PLabelsRepository protocol
- Add GetLabelsUseCase for fetching labels
- Update BookmarkMapper to handle labels
- Add LabelsView and LabelsViewModel for UI
- Update BookmarksView and BookmarkLabelsView to display labels
- Add green2 color asset for labels
- Update API and repository layers to support labels
This commit is contained in:
Ilyas Hallak 2025-07-09 22:28:19 +02:00
parent d2e8228903
commit 3e6db364b5
19 changed files with 236 additions and 25 deletions

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5B",
"green" : "0x4D",
"red" : "0x00"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x5B",
"green" : "0x4D",
"red" : "0x00"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -10,13 +10,14 @@ import Foundation
protocol PAPI {
var tokenProvider: TokenProvider { get }
func login(endpoint: String, username: String, password: String) async throws -> UserDto
func getBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPageDto
func getBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPageDto
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
func searchBookmarks(search: String) async throws -> BookmarksPageDto
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
}
class API: PAPI {
@ -180,12 +181,12 @@ class API: PAPI {
return try JSONDecoder().decode(UserDto.self, from: data)
}
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPageDto {
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPageDto {
var endpoint = "/api/bookmarks"
var queryItems: [URLQueryItem] = []
// Query-Parameter basierend auf State hinzufügen
if let state = state {
if let state {
switch state {
case .unread:
queryItems.append(URLQueryItem(name: "is_archived", value: "false"))
@ -199,24 +200,28 @@ class API: PAPI {
}
}
if let limit = limit {
if let limit {
queryItems.append(URLQueryItem(name: "limit", value: "\(limit)"))
}
if let offset = offset {
if let offset {
queryItems.append(URLQueryItem(name: "offset", value: "\(offset)"))
}
if let search = search {
if let search {
queryItems.append(URLQueryItem(name: "search", value: search))
}
// type-Parameter als Array von BookmarkType
if let type = type, !type.isEmpty {
if let type, !type.isEmpty {
for t in type {
queryItems.append(URLQueryItem(name: "type", value: t.rawValue))
}
}
if let tag {
queryItems.append(URLQueryItem(name: "labels", value: tag))
}
if !queryItems.isEmpty {
let queryString = queryItems.map { "\($0.name)=\($0.value ?? "")" }.joined(separator: "&")
endpoint += "?\(queryString)"
@ -350,6 +355,13 @@ class API: PAPI {
links: links
)
}
func getBookmarkLabels() async throws -> [BookmarkLabelDto] {
return try await makeJSONRequest(
endpoint: "/api/bookmarks/labels",
responseType: [BookmarkLabelDto].self
)
}
}
enum HTTPMethod: String {

View File

@ -0,0 +1,18 @@
import Foundation
struct BookmarkLabelDto: Codable, Identifiable {
var id: String { get { href } }
let name: String
let count: Int
let href: String
enum CodingKeys: String, CodingKey {
case name, count, href
}
init(name: String, count: Int, href: String) {
self.name = name
self.count = count
self.href = href
}
}

View File

@ -71,3 +71,9 @@ extension ImageResourceDto {
return ImageResource(src: src, height: height, width: width)
}
}
extension BookmarkLabelDto {
func toDomain() -> BookmarkLabel {
return BookmarkLabel(name: self.name, count: self.count, href: self.href)
}
}

View File

@ -1,7 +1,7 @@
import Foundation
protocol PBookmarksRepository {
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPage
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
func fetchBookmark(id: String) async throws -> BookmarkDetail
func fetchBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String
@ -17,8 +17,8 @@ class BookmarksRepository: PBookmarksRepository {
self.api = api
}
func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage {
let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type)
func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPage {
let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type, tag: tag)
return bookmarkDtos.toDomain()
}

View File

@ -0,0 +1,14 @@
import Foundation
class LabelsRepository: PLabelsRepository {
private let api: PAPI
init(api: PAPI) {
self.api = api
}
func getLabels() async throws -> [BookmarkLabel] {
let dtos = try await api.getBookmarkLabels()
return dtos.map { $0.toDomain() }
}
}

View File

@ -17,6 +17,7 @@ struct BookmarkDetail {
let labels: [String]
let thumbnailUrl: String
let imageUrl: String
var content: String?
}
extension BookmarkDetail {

View File

@ -0,0 +1,15 @@
import Foundation
struct BookmarkLabel: Identifiable, Equatable, Hashable {
let id: String // kann href oder name sein, je nach Backend
let name: String
let count: Int
let href: String
init(name: String, count: Int, href: String) {
self.name = name
self.count = count
self.href = href
self.id = href // oder name, je nach Backend-Eindeutigkeit
}
}

View File

@ -0,0 +1,5 @@
import Foundation
protocol PLabelsRepository {
func getLabels() async throws -> [BookmarkLabel]
}

View File

@ -7,8 +7,8 @@ class GetBookmarksUseCase {
self.repository = repository
}
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage {
var allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search, type: type)
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPage {
var allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search, type: type, tag: tag)
if let state = state {
allBookmarks.bookmarks = allBookmarks.bookmarks.filter { bookmark in

View File

@ -0,0 +1,13 @@
import Foundation
class GetLabelsUseCase {
private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) {
self.labelsRepository = labelsRepository
}
func execute() async throws -> [BookmarkLabel] {
return try await labelsRepository.getLabels()
}
}

View File

@ -30,6 +30,7 @@ struct BookmarkLabelsView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Abbrechen") {
dismiss()
}
@ -172,4 +173,4 @@ struct LabelChip: View {
#Preview {
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"])
}
}

View File

@ -15,9 +15,17 @@ struct BookmarksView: View {
let state: BookmarkState
let type: [BookmarkType]
@Binding var selectedBookmark: Bookmark?
let tag: String?
// MARK: Initializer
init(state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
self.state = state
self.type = type
self._selectedBookmark = selectedBookmark
self.tag = tag
}
// MARK: Environments
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@ -147,7 +155,7 @@ struct BookmarksView: View {
}*/
.onAppear {
Task {
await viewModel.loadBookmarks(state: state, type: type)
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
}
}
.onChange(of: showingAddBookmark) { oldValue, newValue in

View File

@ -13,11 +13,13 @@ class BookmarksViewModel {
var errorMessage: String?
var currentState: BookmarkState = .unread
var currentType = [BookmarkType.article]
var currentTag: String? = nil
var showingAddBookmarkFromShare = false
var shareURL = ""
var shareTitle = ""
private var cancellables = Set<AnyCancellable>()
private var limit = 20
private var offset = 0
@ -74,11 +76,12 @@ class BookmarksViewModel {
}
@MainActor
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article]) async {
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
isLoading = true
errorMessage = nil
currentState = state
currentType = type
currentTag = tag
offset = 0 // Offset zurücksetzen
hasMoreData = true // Pagination zurücksetzen
@ -89,10 +92,11 @@ class BookmarksViewModel {
limit: limit,
offset: offset,
search: searchQuery,
type: type
type: type,
tag: tag
)
bookmarks = newBookmarks
hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen, ob weitere Daten verfügbar sind
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // Prüfen, ob weitere Daten verfügbar sind
} catch {
errorMessage = "Fehler beim Laden der Bookmarks"
bookmarks = nil
@ -114,9 +118,10 @@ class BookmarksViewModel {
state: currentState,
limit: limit,
offset: offset,
type: currentType)
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) // Neue Bookmarks hinzufügen
hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen,
type: currentType,
tag: currentTag)
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
} catch {
errorMessage = "Fehler beim Nachladen der Bookmarks"
}

View File

@ -15,6 +15,8 @@ protocol UseCaseFactory {
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> GetLabelsUseCase
func makeAddTextToSpeechQueueUseCase() -> AddTextToSpeechQueueUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
@ -88,4 +90,14 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase {
return RemoveLabelsFromBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetLabelsUseCase() -> GetLabelsUseCase {
let api = API(tokenProvider: CoreDataTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return GetLabelsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> AddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase()
}
}

View File

@ -0,0 +1,40 @@
import SwiftUI
struct LabelsView: View {
@State var viewModel = LabelsViewModel()
@State private var selectedTag: String? = nil
@State private var selectedBookmark: Bookmark? = nil
var body: some View {
VStack(alignment: .leading) {
if viewModel.isLoading {
ProgressView()
} else if let errorMessage = viewModel.errorMessage {
Text("Fehler: \(errorMessage)")
.foregroundColor(.red)
} else {
List {
ForEach(viewModel.labels, id: \.href) { label in
NavigationLink {
BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name)
.navigationTitle("\(label.name) (\(label.count))")
} label: {
HStack {
Text(label.name)
Spacer()
Text("\(label.count)")
.foregroundColor(.secondary)
}
}
}
}
}
}
.onAppear {
Task {
await viewModel.loadLabels()
}
}
}
}

View File

@ -0,0 +1,23 @@
import Foundation
import Observation
@Observable
class LabelsViewModel {
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
var labels: [BookmarkLabel] = []
var isLoading = false
var errorMessage: String? = nil
@MainActor
func loadLabels() async {
isLoading = true
errorMessage = nil
do {
labels = try await getLabelsUseCase.execute()
} catch {
errorMessage = "Fehler beim Laden der Labels"
}
isLoading = false
}
}

View File

@ -41,7 +41,7 @@ struct PadSidebarView: View {
.listRowBackground(Color(R.color.menu_sidebar_bg))
}
}
}
}
.listRowBackground(Color(R.color.menu_sidebar_bg))
.background(Color(R.color.menu_sidebar_bg))
@ -86,7 +86,7 @@ struct PadSidebarView: View {
case .pictures:
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
case .tags:
Text("Tags")
LabelsView()
}
}
.navigationTitle(selectedTab.label)

View File

@ -126,7 +126,7 @@ struct SettingsServerView: View {
Button("Debug-Anmeldung") {
viewModel.username = "admin"
viewModel.password = "Diggah123"
viewModel.endpoint = "https://keep.mnk.any64.de"
viewModel.endpoint = "https://readeck.mnk.any64.de"
}
.font(.caption)
.foregroundColor(.secondary)