Compare commits
5 Commits
2c5b51ca3a
...
e68959afce
| Author | SHA1 | Date | |
|---|---|---|---|
| e68959afce | |||
| 9b89e58115 | |||
| 09f1ddea58 | |||
| 3e6db364b5 | |||
| d2e8228903 |
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"editor.fontSize": 14
|
||||||
|
}
|
||||||
@ -3,6 +3,22 @@
|
|||||||
"strings" : {
|
"strings" : {
|
||||||
"" : {
|
"" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%@ (%lld)" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$@ (%2$lld)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"%lld" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld Artikel in der Queue" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld min" : {
|
"%lld min" : {
|
||||||
|
|
||||||
@ -10,24 +26,41 @@
|
|||||||
"%lld Minuten" : {
|
"%lld Minuten" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"12 min • Today • example.com" : {
|
"%lld." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Abbrechen" : {
|
"%lld/%lld" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "new",
|
||||||
"value" : "Abbrechen"
|
"value" : "%1$lld/%2$lld"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"12 min • Today • example.com" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Abbrechen" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Abmelden" : {
|
"Abmelden" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Add Item" : {
|
"Aktuelle Labels" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"all" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Ale"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Anmelden & speichern" : {
|
"Anmelden & speichern" : {
|
||||||
|
|
||||||
@ -37,6 +70,9 @@
|
|||||||
},
|
},
|
||||||
"Artikel automatisch als gelesen markieren" : {
|
"Artikel automatisch als gelesen markieren" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Artikel vorlesen" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Automatischer Sync" : {
|
"Automatischer Sync" : {
|
||||||
|
|
||||||
@ -61,17 +97,6 @@
|
|||||||
},
|
},
|
||||||
"Debug-Anmeldung" : {
|
"Debug-Anmeldung" : {
|
||||||
|
|
||||||
},
|
|
||||||
"done" : {
|
|
||||||
"extractionState" : "manual",
|
|
||||||
"localizations" : {
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Fertig"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"Einfügen" : {
|
"Einfügen" : {
|
||||||
|
|
||||||
@ -111,6 +136,9 @@
|
|||||||
},
|
},
|
||||||
"Fehler" : {
|
"Fehler" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Fehler: %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Fertig" : {
|
"Fertig" : {
|
||||||
|
|
||||||
@ -118,22 +146,17 @@
|
|||||||
"Fertig mit Lesen?" : {
|
"Fertig mit Lesen?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"font_settings_title" : {
|
"Fortschritt: %lld%%" : {
|
||||||
"extractionState" : "manual",
|
|
||||||
"localizations" : {
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Schrift-Einstellungen"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"Füge einen neuen Link zu deiner Sammlung hinzu" : {
|
"Füge einen neuen Link zu deiner Sammlung hinzu" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
|
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Geschwindigkeit" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"https://example.com" : {
|
"https://example.com" : {
|
||||||
|
|
||||||
@ -150,7 +173,7 @@
|
|||||||
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
|
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Item at %@" : {
|
"Keine Artikel in der Queue" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Keine Bookmarks" : {
|
"Keine Bookmarks" : {
|
||||||
@ -161,15 +184,37 @@
|
|||||||
},
|
},
|
||||||
"Keine Ergebnisse" : {
|
"Keine Ergebnisse" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Keine Labels vorhanden" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Key" : {
|
||||||
|
"extractionState" : "manual"
|
||||||
|
},
|
||||||
|
"Label eingeben..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Labels" : {
|
"Labels" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Labels verwalten" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Lade %@..." : {
|
"Lade %@..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Lade Artikel..." : {
|
"Lade Artikel..." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Lese %lld/%lld: " : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Lese %1$lld/%2$lld: "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Leseeinstellungen" : {
|
"Leseeinstellungen" : {
|
||||||
|
|
||||||
@ -185,6 +230,9 @@
|
|||||||
},
|
},
|
||||||
"Neues Bookmark" : {
|
"Neues Bookmark" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Neues Label hinzufügen" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"OK" : {
|
"OK" : {
|
||||||
|
|
||||||
@ -216,10 +264,7 @@
|
|||||||
"Schriftgröße" : {
|
"Schriftgröße" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Select a bookmark" : {
|
"Select a bookmark or tag" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Select an item" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Server-Endpunkt" : {
|
"Server-Endpunkt" : {
|
||||||
@ -266,12 +311,18 @@
|
|||||||
},
|
},
|
||||||
"Version %@" : {
|
"Version %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Vorlese-Queue" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Vorschau" : {
|
"Vorschau" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Website" : {
|
"Website" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Weiterhören" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Wiederherstellen" : {
|
"Wiederherstellen" : {
|
||||||
|
|
||||||
|
|||||||
@ -319,7 +319,6 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
"fr-CA",
|
|
||||||
de,
|
de,
|
||||||
);
|
);
|
||||||
mainGroup = 5D45F9BF2DF858680048D5B8;
|
mainGroup = 5D45F9BF2DF858680048D5B8;
|
||||||
|
|||||||
38
readeck/Assets.xcassets/green2.colorset/Contents.json
Normal file
38
readeck/Assets.xcassets/green2.colorset/Contents.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x5A",
|
||||||
|
"green" : "0x4A",
|
||||||
|
"red" : "0x1F"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x5A",
|
||||||
|
"green" : "0x4A",
|
||||||
|
"red" : "0x1F"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,13 +10,14 @@ import Foundation
|
|||||||
protocol PAPI {
|
protocol PAPI {
|
||||||
var tokenProvider: TokenProvider { get }
|
var tokenProvider: TokenProvider { get }
|
||||||
func login(endpoint: String, username: String, password: String) async throws -> UserDto
|
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 getBookmark(id: String) async throws -> BookmarkDetailDto
|
||||||
func getBookmarkArticle(id: String) async throws -> String
|
func getBookmarkArticle(id: String) async throws -> String
|
||||||
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
|
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
|
||||||
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws
|
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws
|
||||||
func deleteBookmark(id: String) async throws
|
func deleteBookmark(id: String) async throws
|
||||||
func searchBookmarks(search: String) async throws -> BookmarksPageDto
|
func searchBookmarks(search: String) async throws -> BookmarksPageDto
|
||||||
|
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
|
||||||
}
|
}
|
||||||
|
|
||||||
class API: PAPI {
|
class API: PAPI {
|
||||||
@ -180,12 +181,12 @@ class API: PAPI {
|
|||||||
return try JSONDecoder().decode(UserDto.self, from: data)
|
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 endpoint = "/api/bookmarks"
|
||||||
var queryItems: [URLQueryItem] = []
|
var queryItems: [URLQueryItem] = []
|
||||||
|
|
||||||
// Query-Parameter basierend auf State hinzufügen
|
// Query-Parameter basierend auf State hinzufügen
|
||||||
if let state = state {
|
if let state {
|
||||||
switch state {
|
switch state {
|
||||||
case .unread:
|
case .unread:
|
||||||
queryItems.append(URLQueryItem(name: "is_archived", value: "false"))
|
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)"))
|
queryItems.append(URLQueryItem(name: "limit", value: "\(limit)"))
|
||||||
}
|
}
|
||||||
if let offset = offset {
|
if let offset {
|
||||||
queryItems.append(URLQueryItem(name: "offset", value: "\(offset)"))
|
queryItems.append(URLQueryItem(name: "offset", value: "\(offset)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let search = search {
|
if let search {
|
||||||
queryItems.append(URLQueryItem(name: "search", value: search))
|
queryItems.append(URLQueryItem(name: "search", value: search))
|
||||||
}
|
}
|
||||||
|
|
||||||
// type-Parameter als Array von BookmarkType
|
// type-Parameter als Array von BookmarkType
|
||||||
if let type = type, !type.isEmpty {
|
if let type, !type.isEmpty {
|
||||||
for t in type {
|
for t in type {
|
||||||
queryItems.append(URLQueryItem(name: "type", value: t.rawValue))
|
queryItems.append(URLQueryItem(name: "type", value: t.rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let tag {
|
||||||
|
queryItems.append(URLQueryItem(name: "labels", value: tag))
|
||||||
|
}
|
||||||
|
|
||||||
if !queryItems.isEmpty {
|
if !queryItems.isEmpty {
|
||||||
let queryString = queryItems.map { "\($0.name)=\($0.value ?? "")" }.joined(separator: "&")
|
let queryString = queryItems.map { "\($0.name)=\($0.value ?? "")" }.joined(separator: "&")
|
||||||
endpoint += "?\(queryString)"
|
endpoint += "?\(queryString)"
|
||||||
@ -350,6 +355,13 @@ class API: PAPI {
|
|||||||
links: links
|
links: links
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getBookmarkLabels() async throws -> [BookmarkLabelDto] {
|
||||||
|
return try await makeJSONRequest(
|
||||||
|
endpoint: "/api/bookmarks/labels",
|
||||||
|
responseType: [BookmarkLabelDto].self
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HTTPMethod: String {
|
enum HTTPMethod: String {
|
||||||
|
|||||||
18
readeck/Data/API/DTOs/BookmarkLabelDto.swift
Normal file
18
readeck/Data/API/DTOs/BookmarkLabelDto.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -71,3 +71,9 @@ extension ImageResourceDto {
|
|||||||
return ImageResource(src: src, height: height, width: width)
|
return ImageResource(src: src, height: height, width: width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension BookmarkLabelDto {
|
||||||
|
func toDomain() -> BookmarkLabel {
|
||||||
|
return BookmarkLabel(name: self.name, count: self.count, href: self.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,15 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol PBookmarksRepository {
|
|
||||||
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPage
|
|
||||||
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 searchBookmarks(search: String) async throws -> BookmarksPage
|
|
||||||
}
|
|
||||||
|
|
||||||
class BookmarksRepository: PBookmarksRepository {
|
class BookmarksRepository: PBookmarksRepository {
|
||||||
private var api: PAPI
|
private var api: PAPI
|
||||||
|
|
||||||
@ -17,8 +7,8 @@ class BookmarksRepository: PBookmarksRepository {
|
|||||||
self.api = api
|
self.api = api
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage {
|
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)
|
let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type, tag: tag)
|
||||||
return bookmarkDtos.toDomain()
|
return bookmarkDtos.toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,8 +28,10 @@ class BookmarksRepository: PBookmarksRepository {
|
|||||||
hasArticle: bookmarkDetailDto.hasArticle,
|
hasArticle: bookmarkDetailDto.hasArticle,
|
||||||
isMarked: bookmarkDetailDto.isMarked,
|
isMarked: bookmarkDetailDto.isMarked,
|
||||||
isArchived: bookmarkDetailDto.isArchived,
|
isArchived: bookmarkDetailDto.isArchived,
|
||||||
|
labels: bookmarkDetailDto.labels,
|
||||||
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
|
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
|
||||||
imageUrl: bookmarkDetailDto.resources.image?.src ?? ""
|
imageUrl: bookmarkDetailDto.resources.image?.src ?? "",
|
||||||
|
lang: bookmarkDetailDto.lang ?? ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
readeck/Data/Repository/LabelsRepository.swift
Normal file
14
readeck/Data/Repository/LabelsRepository.swift
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,8 +14,11 @@ struct BookmarkDetail {
|
|||||||
let hasArticle: Bool
|
let hasArticle: Bool
|
||||||
let isMarked: Bool
|
let isMarked: Bool
|
||||||
var isArchived: Bool
|
var isArchived: Bool
|
||||||
|
let labels: [String]
|
||||||
let thumbnailUrl: String
|
let thumbnailUrl: String
|
||||||
let imageUrl: String
|
let imageUrl: String
|
||||||
|
let lang: String
|
||||||
|
var content: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
extension BookmarkDetail {
|
extension BookmarkDetail {
|
||||||
@ -33,7 +36,9 @@ extension BookmarkDetail {
|
|||||||
hasArticle: false,
|
hasArticle: false,
|
||||||
isMarked: false,
|
isMarked: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
|
labels: [],
|
||||||
thumbnailUrl: "",
|
thumbnailUrl: "",
|
||||||
imageUrl: ""
|
imageUrl: "",
|
||||||
|
lang: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
15
readeck/Domain/Model/BookmarkLabel.swift
Normal file
15
readeck/Domain/Model/BookmarkLabel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -59,4 +59,12 @@ extension BookmarkUpdateRequest {
|
|||||||
static func updateLabels(_ labels: [String]) -> BookmarkUpdateRequest {
|
static func updateLabels(_ labels: [String]) -> BookmarkUpdateRequest {
|
||||||
return BookmarkUpdateRequest(labels: labels)
|
return BookmarkUpdateRequest(labels: labels)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func addLabels(_ labels: [String]) -> BookmarkUpdateRequest {
|
||||||
|
return BookmarkUpdateRequest(addLabels: labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func removeLabels(_ labels: [String]) -> BookmarkUpdateRequest {
|
||||||
|
return BookmarkUpdateRequest(removeLabels: labels)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
16
readeck/Domain/Protocols/PBookmarksRepository.swift
Normal file
16
readeck/Domain/Protocols/PBookmarksRepository.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// PBookmarksRepository.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 14.07.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
protocol PBookmarksRepository {
|
||||||
|
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
|
||||||
|
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
|
||||||
|
func deleteBookmark(id: String) async throws
|
||||||
|
func searchBookmarks(search: String) async throws -> BookmarksPage
|
||||||
|
}
|
||||||
5
readeck/Domain/Protocols/PLabelsRepository.swift
Normal file
5
readeck/Domain/Protocols/PLabelsRepository.swift
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PLabelsRepository {
|
||||||
|
func getLabels() async throws -> [BookmarkLabel]
|
||||||
|
}
|
||||||
43
readeck/Domain/UseCase/AddLabelsToBookmarkUseCase.swift
Normal file
43
readeck/Domain/UseCase/AddLabelsToBookmarkUseCase.swift
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class AddLabelsToBookmarkUseCase {
|
||||||
|
private let repository: PBookmarksRepository
|
||||||
|
|
||||||
|
init(repository: PBookmarksRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkId: String, labels: [String]) async throws {
|
||||||
|
// Validierung der Labels
|
||||||
|
guard !labels.isEmpty else {
|
||||||
|
throw BookmarkUpdateError.emptyLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entferne leere Labels und Duplikate
|
||||||
|
let cleanLabels = Array(Set(labels.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||||
|
|
||||||
|
guard !cleanLabels.isEmpty else {
|
||||||
|
throw BookmarkUpdateError.emptyLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = BookmarkUpdateRequest.addLabels(cleanLabels)
|
||||||
|
try await repository.updateBookmark(id: bookmarkId, updateRequest: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience method für einzelne Labels
|
||||||
|
func execute(bookmarkId: String, label: String) async throws {
|
||||||
|
try await execute(bookmarkId: bookmarkId, labels: [label])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom error für Label-Operationen
|
||||||
|
enum BookmarkUpdateError: LocalizedError {
|
||||||
|
case emptyLabels
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyLabels:
|
||||||
|
return "Labels können nicht leer sein"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift
Normal file
19
readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class AddTextToSpeechQueueUseCase {
|
||||||
|
private let speechQueue: SpeechQueue
|
||||||
|
|
||||||
|
init(speechQueue: SpeechQueue = .shared) {
|
||||||
|
self.speechQueue = speechQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkDetail: BookmarkDetail) {
|
||||||
|
var text = bookmarkDetail.title + "\n"
|
||||||
|
if let content = bookmarkDetail.content {
|
||||||
|
text += content.stripHTML
|
||||||
|
} else {
|
||||||
|
text += bookmarkDetail.description.stripHTML
|
||||||
|
}
|
||||||
|
speechQueue.enqueue(bookmarkDetail.toSpeechQueueItem(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,8 +7,8 @@ class GetBookmarksUseCase {
|
|||||||
self.repository = repository
|
self.repository = repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage {
|
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)
|
var allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search, type: type, tag: tag)
|
||||||
|
|
||||||
if let state = state {
|
if let state = state {
|
||||||
allBookmarks.bookmarks = allBookmarks.bookmarks.filter { bookmark in
|
allBookmarks.bookmarks = allBookmarks.bookmarks.filter { bookmark in
|
||||||
|
|||||||
13
readeck/Domain/UseCase/GetLabelsUseCase.swift
Normal file
13
readeck/Domain/UseCase/GetLabelsUseCase.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
13
readeck/Domain/UseCase/ReadBookmarkUseCase.swift
Normal file
13
readeck/Domain/UseCase/ReadBookmarkUseCase.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class ReadBookmarkUseCase {
|
||||||
|
private let addToSpeechQueue: AddTextToSpeechQueueUseCase
|
||||||
|
|
||||||
|
init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) {
|
||||||
|
self.addToSpeechQueue = addToSpeechQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkDetail: BookmarkDetail) {
|
||||||
|
addToSpeechQueue.execute(bookmarkDetail: bookmarkDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
readeck/Domain/UseCase/RemoveLabelsFromBookmarkUseCase.swift
Normal file
31
readeck/Domain/UseCase/RemoveLabelsFromBookmarkUseCase.swift
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class RemoveLabelsFromBookmarkUseCase {
|
||||||
|
private let repository: PBookmarksRepository
|
||||||
|
|
||||||
|
init(repository: PBookmarksRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkId: String, labels: [String]) async throws {
|
||||||
|
// Validierung der Labels
|
||||||
|
guard !labels.isEmpty else {
|
||||||
|
throw BookmarkUpdateError.emptyLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entferne leere Labels und Duplikate
|
||||||
|
let cleanLabels = Array(Set(labels.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||||
|
|
||||||
|
guard !cleanLabels.isEmpty else {
|
||||||
|
throw BookmarkUpdateError.emptyLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = BookmarkUpdateRequest.removeLabels(cleanLabels)
|
||||||
|
try await repository.updateBookmark(id: bookmarkId, updateRequest: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience method für einzelne Labels
|
||||||
|
func execute(bookmarkId: String, label: String) async throws {
|
||||||
|
try await execute(bookmarkId: bookmarkId, labels: [label])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,4 +41,14 @@ class UpdateBookmarkUseCase {
|
|||||||
let request = BookmarkUpdateRequest.updateLabels(labels)
|
let request = BookmarkUpdateRequest.updateLabels(labels)
|
||||||
try await execute(bookmarkId: bookmarkId, updateRequest: request)
|
try await execute(bookmarkId: bookmarkId, updateRequest: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addLabels(bookmarkId: String, labels: [String]) async throws {
|
||||||
|
let request = BookmarkUpdateRequest.addLabels(labels)
|
||||||
|
try await execute(bookmarkId: bookmarkId, updateRequest: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeLabels(bookmarkId: String, labels: [String]) async throws {
|
||||||
|
let request = BookmarkUpdateRequest.removeLabels(labels)
|
||||||
|
try await execute(bookmarkId: bookmarkId, updateRequest: request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -16,9 +16,13 @@
|
|||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
<string>green</string>
|
<string>green2</string>
|
||||||
<key>UIImageName</key>
|
<key>UIImageName</key>
|
||||||
<string>readeck</string>
|
<string>readeck</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -6,8 +6,10 @@ struct BookmarkDetailView: View {
|
|||||||
@State private var viewModel = BookmarkDetailViewModel()
|
@State private var viewModel = BookmarkDetailViewModel()
|
||||||
@State private var webViewHeight: CGFloat = 300
|
@State private var webViewHeight: CGFloat = 300
|
||||||
@State private var showingFontSettings = false
|
@State private var showingFontSettings = false
|
||||||
|
@State private var showingLabelsSheet = false
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
|
|
||||||
private let headerHeight: CGFloat = 260
|
private let headerHeight: CGFloat = 320
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
@ -29,10 +31,18 @@ struct BookmarkDetailView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: {
|
HStack(spacing: 12) {
|
||||||
showingFontSettings = true
|
Button(action: {
|
||||||
}) {
|
showingLabelsSheet = true
|
||||||
Image(systemName: "textformat")
|
}) {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showingFontSettings = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "textformat")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,6 +67,9 @@ struct BookmarkDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingLabelsSheet) {
|
||||||
|
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||||
|
}
|
||||||
.onChange(of: showingFontSettings) { _, isShowing in
|
.onChange(of: showingFontSettings) { _, isShowing in
|
||||||
if !isShowing {
|
if !isShowing {
|
||||||
// Reload settings when sheet is dismissed
|
// Reload settings when sheet is dismissed
|
||||||
@ -65,6 +78,14 @@ struct BookmarkDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: showingLabelsSheet) { _, isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
// Reload bookmark detail when labels sheet is dismissed
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshBookmarkDetail(id: bookmarkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||||
await viewModel.loadArticleContent(id: bookmarkId)
|
await viewModel.loadArticleContent(id: bookmarkId)
|
||||||
@ -91,13 +112,22 @@ struct BookmarkDetailView: View {
|
|||||||
.fill(Color.gray.opacity(0.4))
|
.fill(Color.gray.opacity(0.4))
|
||||||
.frame(width: geometry.size.width, height: headerHeight)
|
.frame(width: geometry.size.width, height: headerHeight)
|
||||||
}
|
}
|
||||||
|
// Gradient overlay für bessere Button-Sichtbarkeit
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
gradient: Gradient(colors: [Color.black.opacity(0.6), Color.clear]),
|
gradient: Gradient(colors: [
|
||||||
|
Color.black.opacity(1.0),
|
||||||
|
Color.black.opacity(0.9),
|
||||||
|
Color.black.opacity(0.7),
|
||||||
|
Color.black.opacity(0.4),
|
||||||
|
Color.black.opacity(0.2),
|
||||||
|
Color.clear
|
||||||
|
]),
|
||||||
startPoint: .top,
|
startPoint: .top,
|
||||||
endPoint: .bottom
|
endPoint: .bottom
|
||||||
)
|
)
|
||||||
.frame(height: 120)
|
.frame(height: 240)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.offset(y: (offset > 0 ? -offset : 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: headerHeight)
|
.frame(height: headerHeight)
|
||||||
@ -155,6 +185,38 @@ struct BookmarkDetailView: View {
|
|||||||
}
|
}
|
||||||
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
||||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit")
|
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit")
|
||||||
|
|
||||||
|
// Labels section
|
||||||
|
if !viewModel.bookmarkDetail.labels.isEmpty {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 2)
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(viewModel.bookmarkDetail.labels, id: \.self) { label in
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.accentColor.opacity(0.1))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
metaRow(icon: "safari") {
|
metaRow(icon: "safari") {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||||
@ -164,10 +226,20 @@ struct BookmarkDetailView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metaRow(icon: "speaker.wave.2") {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.addBookmarkToSpeechQueue()
|
||||||
|
playerUIState.showPlayer()
|
||||||
|
}) {
|
||||||
|
Text("Artikel vorlesen")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ViewBuilder für Meta-Infos
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func metaRow(icon: String, text: String) -> some View {
|
private func metaRow(icon: String, text: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ class BookmarkDetailViewModel {
|
|||||||
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
|
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
|
||||||
private let loadSettingsUseCase: LoadSettingsUseCase
|
private let loadSettingsUseCase: LoadSettingsUseCase
|
||||||
private let updateBookmarkUseCase: UpdateBookmarkUseCase
|
private let updateBookmarkUseCase: UpdateBookmarkUseCase
|
||||||
|
private let addTextToSpeechQueueUseCase: AddTextToSpeechQueueUseCase
|
||||||
|
|
||||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||||
var articleContent: String = ""
|
var articleContent: String = ""
|
||||||
@ -22,6 +23,7 @@ class BookmarkDetailViewModel {
|
|||||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||||
|
self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -73,4 +75,14 @@ class BookmarkDetailViewModel {
|
|||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func refreshBookmarkDetail(id: String) async {
|
||||||
|
await loadBookmarkDetail(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addBookmarkToSpeechQueue() {
|
||||||
|
bookmarkDetail.content = articleContent
|
||||||
|
addTextToSpeechQueueUseCase.execute(bookmarkDetail: bookmarkDetail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
176
readeck/UI/BookmarkDetail/BookmarkLabelsView.swift
Normal file
176
readeck/UI/BookmarkDetail/BookmarkLabelsView.swift
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BookmarkLabelsView: View {
|
||||||
|
let bookmarkId: String
|
||||||
|
@State private var viewModel: BookmarkLabelsViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
init(bookmarkId: String, initialLabels: [String]) {
|
||||||
|
self.bookmarkId = bookmarkId
|
||||||
|
self._viewModel = State(initialValue: BookmarkLabelsViewModel(initialLabels: initialLabels))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Add new label section
|
||||||
|
addLabelSection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal, -16)
|
||||||
|
|
||||||
|
// Current labels section
|
||||||
|
currentLabelsSection
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
.navigationTitle("Labels verwalten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Fertig") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Fehler", isPresented: $viewModel.showErrorAlert) {
|
||||||
|
Button("OK") { }
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.errorMessage ?? "Unbekannter Fehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addLabelSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Neues Label hinzufügen")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TextField("Label eingeben...", text: $viewModel.newLabelText)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.onSubmit {
|
||||||
|
Task {
|
||||||
|
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
Task {
|
||||||
|
await viewModel.addLabel(to: bookmarkId, label: viewModel.newLabelText)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.newLabelText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentLabelsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Aktuelle Labels")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.currentLabels.isEmpty {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Keine Labels vorhanden")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 32)
|
||||||
|
} else {
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.adaptive(minimum: 80, maximum: 150))
|
||||||
|
], spacing: 4) {
|
||||||
|
ForEach(viewModel.currentLabels, id: \.self) { label in
|
||||||
|
LabelChip(
|
||||||
|
label: label,
|
||||||
|
onRemove: {
|
||||||
|
Task {
|
||||||
|
await viewModel.removeLabel(from: bookmarkId, label: label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LabelChip: View {
|
||||||
|
let label: String
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
|
||||||
|
Button(action: onRemove) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(Color.accentColor.opacity(0.15))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(Color.accentColor.opacity(0.4), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"])
|
||||||
|
}
|
||||||
86
readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift
Normal file
86
readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class BookmarkLabelsViewModel {
|
||||||
|
private let addLabelsUseCase = DefaultUseCaseFactory.shared.makeAddLabelsToBookmarkUseCase()
|
||||||
|
private let removeLabelsUseCase = DefaultUseCaseFactory.shared.makeRemoveLabelsFromBookmarkUseCase()
|
||||||
|
|
||||||
|
var isLoading = false
|
||||||
|
var errorMessage: String?
|
||||||
|
var showErrorAlert = false
|
||||||
|
var currentLabels: [String] = []
|
||||||
|
var newLabelText = ""
|
||||||
|
|
||||||
|
init(initialLabels: [String] = []) {
|
||||||
|
self.currentLabels = initialLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func addLabels(to bookmarkId: String, labels: [String]) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
|
||||||
|
// Update local labels
|
||||||
|
currentLabels.append(contentsOf: labels)
|
||||||
|
currentLabels = Array(Set(currentLabels)) // Remove duplicates
|
||||||
|
} catch let error as BookmarkUpdateError {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showErrorAlert = true
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Fehler beim Hinzufügen der Labels"
|
||||||
|
showErrorAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func addLabel(to bookmarkId: String, label: String) async {
|
||||||
|
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedLabel.isEmpty else { return }
|
||||||
|
|
||||||
|
await addLabels(to: bookmarkId, labels: [trimmedLabel])
|
||||||
|
newLabelText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func removeLabels(from bookmarkId: String, labels: [String]) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await removeLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
|
||||||
|
// Update local labels
|
||||||
|
currentLabels.removeAll { labels.contains($0) }
|
||||||
|
} catch let error as BookmarkUpdateError {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showErrorAlert = true
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Fehler beim Entfernen der Labels"
|
||||||
|
showErrorAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func removeLabel(from bookmarkId: String, label: String) async {
|
||||||
|
await removeLabels(from: bookmarkId, labels: [label])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience method für das Umschalten eines Labels (hinzufügen wenn nicht vorhanden, entfernen wenn vorhanden)
|
||||||
|
@MainActor
|
||||||
|
func toggleLabel(for bookmarkId: String, label: String) async {
|
||||||
|
if currentLabels.contains(label) {
|
||||||
|
await removeLabel(from: bookmarkId, label: label)
|
||||||
|
} else {
|
||||||
|
await addLabel(to: bookmarkId, label: label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLabels(_ labels: [String]) {
|
||||||
|
currentLabels = labels
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,8 +15,17 @@ struct BookmarksView: View {
|
|||||||
|
|
||||||
let state: BookmarkState
|
let state: BookmarkState
|
||||||
let type: [BookmarkType]
|
let type: [BookmarkType]
|
||||||
|
|
||||||
@Binding var selectedBookmark: Bookmark?
|
@Binding var selectedBookmark: Bookmark?
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
|
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
|
// MARK: Environments
|
||||||
|
|
||||||
@ -147,7 +156,7 @@ struct BookmarksView: View {
|
|||||||
}*/
|
}*/
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.loadBookmarks(state: state, type: type)
|
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: showingAddBookmark) { oldValue, newValue in
|
.onChange(of: showingAddBookmark) { oldValue, newValue in
|
||||||
|
|||||||
@ -13,11 +13,13 @@ class BookmarksViewModel {
|
|||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var currentState: BookmarkState = .unread
|
var currentState: BookmarkState = .unread
|
||||||
var currentType = [BookmarkType.article]
|
var currentType = [BookmarkType.article]
|
||||||
|
var currentTag: String? = nil
|
||||||
|
|
||||||
var showingAddBookmarkFromShare = false
|
var showingAddBookmarkFromShare = false
|
||||||
var shareURL = ""
|
var shareURL = ""
|
||||||
var shareTitle = ""
|
var shareTitle = ""
|
||||||
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var limit = 20
|
private var limit = 20
|
||||||
private var offset = 0
|
private var offset = 0
|
||||||
@ -74,11 +76,12 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article]) async {
|
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
currentState = state
|
currentState = state
|
||||||
currentType = type
|
currentType = type
|
||||||
|
currentTag = tag
|
||||||
|
|
||||||
offset = 0 // Offset zurücksetzen
|
offset = 0 // Offset zurücksetzen
|
||||||
hasMoreData = true // Pagination zurücksetzen
|
hasMoreData = true // Pagination zurücksetzen
|
||||||
@ -89,10 +92,11 @@ class BookmarksViewModel {
|
|||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
search: searchQuery,
|
search: searchQuery,
|
||||||
type: type
|
type: type,
|
||||||
|
tag: tag
|
||||||
)
|
)
|
||||||
bookmarks = newBookmarks
|
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 {
|
} catch {
|
||||||
errorMessage = "Fehler beim Laden der Bookmarks"
|
errorMessage = "Fehler beim Laden der Bookmarks"
|
||||||
bookmarks = nil
|
bookmarks = nil
|
||||||
@ -114,9 +118,10 @@ class BookmarksViewModel {
|
|||||||
state: currentState,
|
state: currentState,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
type: currentType)
|
type: currentType,
|
||||||
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) // Neue Bookmarks hinzufügen
|
tag: currentTag)
|
||||||
hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen,
|
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
|
||||||
|
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Fehler beim Nachladen der Bookmarks"
|
errorMessage = "Fehler beim Nachladen der Bookmarks"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// readeck
|
|
||||||
//
|
|
||||||
// Created by Ilyas Hallak on 10.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
|
|
||||||
@FetchRequest(
|
|
||||||
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
|
|
||||||
animation: .default)
|
|
||||||
private var items: FetchedResults<Item>
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
List {
|
|
||||||
ForEach(items) { item in
|
|
||||||
NavigationLink {
|
|
||||||
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
|
|
||||||
} label: {
|
|
||||||
Text(item.timestamp!, formatter: itemFormatter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete(perform: deleteItems)
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
#if os(iOS)
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
EditButton()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
ToolbarItem {
|
|
||||||
Button(action: addItem) {
|
|
||||||
Label("Add Item", systemImage: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text("Select an item")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addItem() {
|
|
||||||
withAnimation {
|
|
||||||
let newItem = Item(context: viewContext)
|
|
||||||
newItem.timestamp = Date()
|
|
||||||
|
|
||||||
do {
|
|
||||||
try viewContext.save()
|
|
||||||
} catch {
|
|
||||||
// Replace this implementation with code to handle the error appropriately.
|
|
||||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
|
||||||
let nsError = error as NSError
|
|
||||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteItems(offsets: IndexSet) {
|
|
||||||
withAnimation {
|
|
||||||
offsets.map { items[$0] }.forEach(viewContext.delete)
|
|
||||||
|
|
||||||
do {
|
|
||||||
try viewContext.save()
|
|
||||||
} catch {
|
|
||||||
// Replace this implementation with code to handle the error appropriately.
|
|
||||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
|
||||||
let nsError = error as NSError
|
|
||||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let itemFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateStyle = .short
|
|
||||||
formatter.timeStyle = .medium
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
|
||||||
}
|
|
||||||
@ -13,6 +13,10 @@ protocol UseCaseFactory {
|
|||||||
func makeLogoutUseCase() -> LogoutUseCase
|
func makeLogoutUseCase() -> LogoutUseCase
|
||||||
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase
|
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase
|
||||||
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase
|
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase
|
||||||
|
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase
|
||||||
|
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase
|
||||||
|
func makeGetLabelsUseCase() -> GetLabelsUseCase
|
||||||
|
func makeAddTextToSpeechQueueUseCase() -> AddTextToSpeechQueueUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
class DefaultUseCaseFactory: UseCaseFactory {
|
class DefaultUseCaseFactory: UseCaseFactory {
|
||||||
@ -78,4 +82,22 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase {
|
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase {
|
||||||
return SaveServerSettingsUseCase(repository: SettingsRepository())
|
return SaveServerSettingsUseCase(repository: SettingsRepository())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase {
|
||||||
|
return AddLabelsToBookmarkUseCase(repository: bookmarksRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
readeck/UI/Labels/LabelsView.swift
Normal file
39
readeck/UI/Labels/LabelsView.swift
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
readeck/UI/Labels/LabelsViewModel.swift
Normal file
23
readeck/UI/Labels/LabelsViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ import SwiftUI
|
|||||||
struct PadSidebarView: View {
|
struct PadSidebarView: View {
|
||||||
@State private var selectedTab: SidebarTab = .unread
|
@State private var selectedTab: SidebarTab = .unread
|
||||||
@State private var selectedBookmark: Bookmark?
|
@State private var selectedBookmark: Bookmark?
|
||||||
|
@State private var selectedTag: BookmarkLabel?
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
|
|
||||||
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
||||||
|
|
||||||
@ -19,6 +21,8 @@ struct PadSidebarView: View {
|
|||||||
ForEach(sidebarTabs, id: \.self) { tab in
|
ForEach(sidebarTabs, id: \.self) { tab in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
|
selectedBookmark = nil
|
||||||
|
selectedTag = nil
|
||||||
}) {
|
}) {
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
.foregroundColor(selectedTab == tab ? .accentColor : .primary)
|
.foregroundColor(selectedTab == tab ? .accentColor : .primary)
|
||||||
@ -31,16 +35,6 @@ struct PadSidebarView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
||||||
}
|
}
|
||||||
|
|
||||||
if tab == .pictures {
|
|
||||||
Group {
|
|
||||||
Spacer()
|
|
||||||
Divider()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
||||||
@ -49,7 +43,6 @@ struct PadSidebarView: View {
|
|||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.safeAreaInset(edge: .bottom, alignment: .center) {
|
.safeAreaInset(edge: .bottom, alignment: .center) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Divider()
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedTab = .settings
|
selectedTab = .settings
|
||||||
}) {
|
}) {
|
||||||
@ -60,43 +53,45 @@ struct PadSidebarView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg))
|
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg))
|
||||||
|
PlayerQueueResumeButton()
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(Color(R.color.menu_sidebar_bg))
|
.background(Color(R.color.menu_sidebar_bg))
|
||||||
}
|
}
|
||||||
} content: {
|
} content: {
|
||||||
Group {
|
GlobalPlayerContainerView {
|
||||||
switch selectedTab {
|
Group {
|
||||||
case .search:
|
switch selectedTab {
|
||||||
SearchBookmarksView(selectedBookmark: $selectedBookmark)
|
case .search:
|
||||||
case .all:
|
SearchBookmarksView(selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
case .all:
|
||||||
case .unread:
|
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
|
case .unread:
|
||||||
case .favorite:
|
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
|
case .favorite:
|
||||||
case .archived:
|
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
|
case .archived:
|
||||||
case .settings:
|
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
|
||||||
SettingsView()
|
case .settings:
|
||||||
case .article:
|
SettingsView()
|
||||||
BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark)
|
case .article:
|
||||||
case .videos:
|
BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark)
|
case .videos:
|
||||||
case .pictures:
|
BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
|
case .pictures:
|
||||||
case .tags:
|
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
|
||||||
Text("Tags")
|
case .tags:
|
||||||
|
LabelsView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle(selectedTab.label)
|
||||||
}
|
}
|
||||||
.navigationTitle(selectedTab.label)
|
|
||||||
|
|
||||||
|
|
||||||
} detail: {
|
} detail: {
|
||||||
if let bookmark = selectedBookmark, selectedTab != .settings {
|
if let bookmark = selectedBookmark, selectedTab != .settings {
|
||||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||||
} else {
|
} else {
|
||||||
Text(selectedTab == .settings ? "" : "Select a bookmark")
|
Text(selectedTab == .settings ? "" : "Select a bookmark or tag")
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,42 +15,54 @@ struct PhoneTabView: View {
|
|||||||
@State private var selectedTabIndex: Int = 0
|
@State private var selectedTabIndex: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTabIndex) {
|
GlobalPlayerContainerView {
|
||||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
TabView(selection: $selectedTabIndex) {
|
||||||
NavigationStack {
|
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||||
tabView(for: tab)
|
NavigationStack {
|
||||||
}
|
|
||||||
.tabItem {
|
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
|
||||||
}
|
|
||||||
.tag(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationStack {
|
|
||||||
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
|
||||||
NavigationLink(tag: tab, selection: $selectedMoreTab) {
|
|
||||||
tabView(for: tab)
|
tabView(for: tab)
|
||||||
.navigationTitle(tab.label)
|
}
|
||||||
} label: {
|
.tabItem {
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
.tag(idx)
|
||||||
}
|
}
|
||||||
.navigationTitle("Mehr")
|
|
||||||
.scrollContentBackground(.hidden)
|
NavigationStack {
|
||||||
.background(Color(R.color.bookmark_list_bg))
|
if let selectedTab = selectedMoreTab {
|
||||||
}
|
tabView(for: selectedTab)
|
||||||
.tabItem {
|
.navigationTitle(selectedTab.label)
|
||||||
Label("Mehr", systemImage: "ellipsis")
|
} else {
|
||||||
}
|
VStack(alignment: .leading) {
|
||||||
.tag(mainTabs.count)
|
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
||||||
.onAppear {
|
NavigationLink {
|
||||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
tabView(for: tab)
|
||||||
selectedMoreTab = nil
|
.navigationTitle(tab.label)
|
||||||
|
} label: {
|
||||||
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
|
}
|
||||||
|
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||||
|
}
|
||||||
|
.navigationTitle("Mehr")
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
|
||||||
|
PlayerQueueResumeButton()
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Mehr", systemImage: "ellipsis")
|
||||||
|
}
|
||||||
|
.tag(mainTabs.count)
|
||||||
|
.onAppear {
|
||||||
|
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||||
|
selectedMoreTab = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accentColor(.accentColor)
|
||||||
}
|
}
|
||||||
.accentColor(.accentColor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -75,7 +87,7 @@ struct PhoneTabView: View {
|
|||||||
case .pictures:
|
case .pictures:
|
||||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
||||||
case .tags:
|
case .tags:
|
||||||
Text("Tags")
|
LabelsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
readeck/UI/Menu/PlayerQueueResumeButton.swift
Normal file
51
readeck/UI/Menu/PlayerQueueResumeButton.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PlayerQueueResumeButton: View {
|
||||||
|
@ObservedObject private var queue = SpeechQueue.shared
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
|
private let playerViewModel = SpeechPlayerViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if queue.hasItems, !playerUIState.isPlayerVisible {
|
||||||
|
Button(action: {
|
||||||
|
playerViewModel.resume()
|
||||||
|
playerUIState.showPlayer()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Vorlese-Queue")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("\(queue.queueItems.count) Artikel in der Queue")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
playerViewModel.resume()
|
||||||
|
playerUIState.showPlayer()
|
||||||
|
}) {
|
||||||
|
Text("Weiterhören")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.accentColor.opacity(0.15))
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color(.systemBackground))
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.transition(.opacity)
|
||||||
|
.animation(.spring(), value: queue.hasItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .all: return "Alle"
|
case .all: return "All"
|
||||||
case .unread: return "Ungelesen"
|
case .unread: return "Ungelesen"
|
||||||
case .favorite: return "Favoriten"
|
case .favorite: return "Favoriten"
|
||||||
case .archived: return "Archiv"
|
case .archived: return "Archiv"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Foundation
|
|||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@State private var selectedTab: SidebarTab = .unread
|
@State private var selectedTab: SidebarTab = .unread
|
||||||
@State var selectedBookmark: Bookmark?
|
@State var selectedBookmark: Bookmark?
|
||||||
|
@StateObject private var playerUIState = PlayerUIState()
|
||||||
|
|
||||||
// sizeClass
|
// sizeClass
|
||||||
@Environment(\.horizontalSizeClass)
|
@Environment(\.horizontalSizeClass)
|
||||||
@ -15,8 +16,10 @@ struct MainTabView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if UIDevice.isPhone {
|
if UIDevice.isPhone {
|
||||||
PhoneTabView()
|
PhoneTabView()
|
||||||
|
.environmentObject(playerUIState)
|
||||||
} else {
|
} else {
|
||||||
PadSidebarView()
|
PadSidebarView()
|
||||||
|
.environmentObject(playerUIState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift
Normal file
40
readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GlobalPlayerContainerView<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
@StateObject private var viewModel = SpeechPlayerViewModel()
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
content
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
if viewModel.hasItems && playerUIState.isPlayerVisible {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
SpeechPlayerView(onClose: { playerUIState.hidePlayer() })
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
Rectangle()
|
||||||
|
.fill(.clear)
|
||||||
|
.frame(height: 49)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(), value: viewModel.hasItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
GlobalPlayerContainerView {
|
||||||
|
Text("Main Content")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
.environmentObject(PlayerUIState())
|
||||||
|
}
|
||||||
18
readeck/UI/SpeechPlayer/PlayerUIState.swift
Normal file
18
readeck/UI/SpeechPlayer/PlayerUIState.swift
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class PlayerUIState: ObservableObject {
|
||||||
|
@Published var isPlayerVisible: Bool = false
|
||||||
|
|
||||||
|
func showPlayer() {
|
||||||
|
isPlayerVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hidePlayer() {
|
||||||
|
isPlayerVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayer() {
|
||||||
|
isPlayerVisible.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
303
readeck/UI/SpeechPlayer/SpeechPlayerView.swift
Normal file
303
readeck/UI/SpeechPlayer/SpeechPlayerView.swift
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SpeechPlayerView: View {
|
||||||
|
@State var viewModel = SpeechPlayerViewModel()
|
||||||
|
@State private var isExpanded = false
|
||||||
|
@State private var dragOffset: CGFloat = 0
|
||||||
|
var onClose: (() -> Void)? = nil
|
||||||
|
|
||||||
|
private let minHeight: CGFloat = 60
|
||||||
|
private let maxHeight: CGFloat = UIScreen.main.bounds.height / 2
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if isExpanded {
|
||||||
|
ExpandedPlayerView(viewModel: viewModel, isExpanded: $isExpanded, onClose: onClose)
|
||||||
|
} else {
|
||||||
|
CollapsedPlayerBar(viewModel: viewModel, isExpanded: $isExpanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: isExpanded ? maxHeight : minHeight)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(12)
|
||||||
|
.shadow(radius: 8, x: 0, y: -2)
|
||||||
|
.offset(y: dragOffset)
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
dragOffset = value.translation.height
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
withAnimation(.spring()) {
|
||||||
|
if value.translation.height < -50 && !isExpanded {
|
||||||
|
isExpanded = true
|
||||||
|
} else if value.translation.height > 50 && isExpanded {
|
||||||
|
isExpanded = false
|
||||||
|
}
|
||||||
|
dragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CollapsedPlayerBar: View {
|
||||||
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
||||||
|
@Binding var isExpanded: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button(action: {
|
||||||
|
if viewModel.isSpeaking {
|
||||||
|
viewModel.pause()
|
||||||
|
} else {
|
||||||
|
viewModel.resume()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(viewModel.currentText.isEmpty ? "Keine Wiedergabe" : viewModel.currentText)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
if viewModel.articleProgress > 0 && viewModel.articleProgress < 1 {
|
||||||
|
ProgressView(value: viewModel.articleProgress)
|
||||||
|
.progressViewStyle(LinearProgressViewStyle(tint: .accentColor))
|
||||||
|
.scaleEffect(y: 0.8)
|
||||||
|
}
|
||||||
|
if viewModel.queueCount > 0 {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "text.line.first.and.arrowtriangle.forward")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("\(viewModel.currentUtteranceIndex + 1)/\(viewModel.queueCount)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onTapGesture {
|
||||||
|
withAnimation(.spring()) { isExpanded.toggle() }
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(action: { viewModel.stop() }) {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Button(action: { withAnimation(.spring()) { isExpanded.toggle() } }) {
|
||||||
|
Image(systemName: "chevron.up")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExpandedPlayerView: View {
|
||||||
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
||||||
|
@Binding var isExpanded: Bool
|
||||||
|
var onClose: (() -> Void)? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Header
|
||||||
|
HStack {
|
||||||
|
Button(action: { onClose?() }) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("Vorlese-Queue")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Spacer()
|
||||||
|
Button(action: { withAnimation(.spring()) { isExpanded = false } }) {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
// Fortschrittsbalken für aktuellen Artikel
|
||||||
|
if viewModel.articleProgress > 0 && viewModel.articleProgress < 1 {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ProgressView(value: viewModel.articleProgress)
|
||||||
|
.progressViewStyle(LinearProgressViewStyle(tint: .accentColor))
|
||||||
|
HStack {
|
||||||
|
Text("Fortschritt: \(Int(viewModel.articleProgress * 100))%")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerControls(viewModel: viewModel)
|
||||||
|
PlayerVolume(viewModel: viewModel)
|
||||||
|
|
||||||
|
if viewModel.queueCount > 0 {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "text.line.first.and.arrowtriangle.forward")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
Text("Lese \(viewModel.currentUtteranceIndex + 1)/\(viewModel.queueCount): ")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(viewModel.queueItems[safe: viewModel.currentUtteranceIndex]?.title ?? "")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
PlayerQueueList(viewModel: viewModel)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlayerControls: View {
|
||||||
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
||||||
|
let rates: [Float] = [0.25, 0.5, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
Button(action: {
|
||||||
|
if viewModel.isSpeaking {
|
||||||
|
viewModel.pause()
|
||||||
|
} else {
|
||||||
|
viewModel.resume()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
Button(action: { viewModel.stop() }) {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Picker("Geschwindigkeit", selection: Binding(
|
||||||
|
get: { viewModel.rate },
|
||||||
|
set: { viewModel.setRate($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(rates, id: \ .self) { value in
|
||||||
|
Text(String(format: "%.2fx", value)).tag(Float(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(maxWidth: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlayerVolume: View {
|
||||||
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "speaker.wave.2.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
Slider(value: Binding(
|
||||||
|
get: { viewModel.volume },
|
||||||
|
set: { viewModel.setVolume($0) }
|
||||||
|
), in: 0...1, step: 0.01)
|
||||||
|
Text(String(format: "%.0f%%", viewModel.volume * 100))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 40, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlayerRate: View {
|
||||||
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
||||||
|
let rates: [Float] = [0.25, 0.5, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "speedometer")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
Picker("Geschwindigkeit", selection: Binding(
|
||||||
|
get: { viewModel.rate },
|
||||||
|
set: { viewModel.setRate($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(rates, id: \ .self) { value in
|
||||||
|
Text(String(format: "%.2fx", value)).tag(Float(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(maxWidth: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlayerQueueList: View {
|
||||||
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
||||||
|
var body: some View {
|
||||||
|
if viewModel.queueCount == 0 {
|
||||||
|
Text("Keine Artikel in der Queue")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 8) {
|
||||||
|
ForEach(Array(viewModel.queueItems.enumerated()), id: \.offset) { index, item in
|
||||||
|
HStack {
|
||||||
|
Text("\(index + 1).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 20, alignment: .leading)
|
||||||
|
Text(item.title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(2)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array safe access helper
|
||||||
|
fileprivate extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SpeechPlayerView()
|
||||||
|
}
|
||||||
96
readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift
Normal file
96
readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class SpeechPlayerViewModel: ObservableObject {
|
||||||
|
private let ttsManager: TTSManager
|
||||||
|
private let speechQueue: SpeechQueue
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
@Published var isSpeaking: Bool = false
|
||||||
|
@Published var currentText: String = ""
|
||||||
|
@Published var queueCount: Int = 0
|
||||||
|
@Published var queueItems: [SpeechQueueItem] = []
|
||||||
|
@Published var hasItems: Bool = false
|
||||||
|
@Published var progress: Double = 0.0
|
||||||
|
@Published var currentUtteranceIndex: Int = 0
|
||||||
|
@Published var totalUtterances: Int = 0
|
||||||
|
@Published var articleProgress: Double = 0.0
|
||||||
|
@Published var volume: Float = 1.0
|
||||||
|
@Published var rate: Float = 0.5
|
||||||
|
|
||||||
|
init(ttsManager: TTSManager = .shared, speechQueue: SpeechQueue = .shared) {
|
||||||
|
self.ttsManager = ttsManager
|
||||||
|
self.speechQueue = speechQueue
|
||||||
|
setupBindings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupBindings() {
|
||||||
|
// TTSManager bindings
|
||||||
|
ttsManager.$isSpeaking
|
||||||
|
.assign(to: \.isSpeaking, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
ttsManager.$currentUtterance
|
||||||
|
.assign(to: \.currentText, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// SpeechQueue bindings
|
||||||
|
speechQueue.$queueItems
|
||||||
|
.assign(to: \.queueItems, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
speechQueue.$queueItems
|
||||||
|
.map { $0.count }
|
||||||
|
.assign(to: \.queueCount, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
speechQueue.$hasItems
|
||||||
|
.assign(to: \.hasItems, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// TTS Progress bindings
|
||||||
|
ttsManager.$progress
|
||||||
|
.assign(to: \.progress, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
ttsManager.$currentUtteranceIndex
|
||||||
|
.assign(to: \.currentUtteranceIndex, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
ttsManager.$totalUtterances
|
||||||
|
.assign(to: \.totalUtterances, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
ttsManager.$articleProgress
|
||||||
|
.assign(to: \.articleProgress, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
ttsManager.$volume
|
||||||
|
.assign(to: \.volume, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
ttsManager.$rate
|
||||||
|
.assign(to: \.rate, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(_ newVolume: Float) {
|
||||||
|
ttsManager.setVolume(newVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRate(_ newRate: Float) {
|
||||||
|
ttsManager.setRate(newRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
ttsManager.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
ttsManager.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
ttsManager.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
153
readeck/UI/Utils/SpeechQueue.swift
Normal file
153
readeck/UI/Utils/SpeechQueue.swift
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
struct SpeechQueueItem: Codable, Equatable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let content: String?
|
||||||
|
let url: String
|
||||||
|
let labels: [String]?
|
||||||
|
let imageUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BookmarkDetail {
|
||||||
|
func toSpeechQueueItem(_ content: String? = nil) -> SpeechQueueItem {
|
||||||
|
return SpeechQueueItem(
|
||||||
|
id: self.id,
|
||||||
|
title: title,
|
||||||
|
content: content ?? self.content,
|
||||||
|
url: url,
|
||||||
|
labels: labels,
|
||||||
|
imageUrl: imageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpeechQueue: ObservableObject {
|
||||||
|
private var queue: [SpeechQueueItem] = []
|
||||||
|
private var isProcessing = false
|
||||||
|
private let ttsManager: TTSManager
|
||||||
|
private let language: String
|
||||||
|
private let queueKey = "tts_queue"
|
||||||
|
|
||||||
|
static let shared = SpeechQueue()
|
||||||
|
|
||||||
|
@Published var queueItems: [SpeechQueueItem] = []
|
||||||
|
@Published var currentText: String = ""
|
||||||
|
@Published var hasItems: Bool = false
|
||||||
|
|
||||||
|
var queueCount: Int {
|
||||||
|
return queueItems.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentItem: SpeechQueueItem? {
|
||||||
|
return queueItems.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(ttsManager: TTSManager = .shared, language: String = "de-DE") {
|
||||||
|
self.ttsManager = ttsManager
|
||||||
|
self.language = language
|
||||||
|
loadQueue()
|
||||||
|
updatePublishedProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(_ item: SpeechQueueItem) {
|
||||||
|
queue.append(item)
|
||||||
|
updatePublishedProperties()
|
||||||
|
saveQueue()
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(contentsOf items: [SpeechQueueItem]) {
|
||||||
|
queue.append(contentsOf: items)
|
||||||
|
updatePublishedProperties()
|
||||||
|
saveQueue()
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
print("[SpeechQueue] stop() aufgerufen")
|
||||||
|
updatePublishedProperties()
|
||||||
|
saveQueue()
|
||||||
|
ttsManager.stop()
|
||||||
|
isProcessing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
print("[SpeechQueue] clear() aufgerufen")
|
||||||
|
queue.removeAll()
|
||||||
|
updatePublishedProperties()
|
||||||
|
saveQueue()
|
||||||
|
ttsManager.stop()
|
||||||
|
isProcessing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePublishedProperties() {
|
||||||
|
queueItems = queue
|
||||||
|
currentText = queue.first?.content ?? ""
|
||||||
|
hasItems = !queue.isEmpty || ttsManager.isCurrentlySpeaking()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processQueue() {
|
||||||
|
guard !isProcessing, !queue.isEmpty else { return }
|
||||||
|
isProcessing = true
|
||||||
|
let next = queue[0]
|
||||||
|
updatePublishedProperties()
|
||||||
|
saveQueue()
|
||||||
|
let currentIndex = queueItems.count - queue.count
|
||||||
|
let textToSpeak = (next.title + "\n" + (next.content ?? "")).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
ttsManager.speak(text: textToSpeak, language: language, utteranceIndex: currentIndex, totalUtterances: queueItems.count)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||||
|
self?.waitForSpeechToFinish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSpeechToFinish() {
|
||||||
|
if ttsManager.isCurrentlySpeaking() {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
|
self?.waitForSpeechToFinish()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !queue.isEmpty {
|
||||||
|
queue.removeFirst()
|
||||||
|
print("[SpeechQueue] Artikel fertig abgespielt und aus Queue entfernt")
|
||||||
|
}
|
||||||
|
self.isProcessing = false
|
||||||
|
self.updatePublishedProperties()
|
||||||
|
self.saveQueue()
|
||||||
|
self.processQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistenz
|
||||||
|
private func saveQueue() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(queue)
|
||||||
|
if let jsonString = String(data: data, encoding: .utf8) {
|
||||||
|
print("[SpeechQueue] Speichere Queue (\(queue.count)) als JSON: \n\(jsonString)")
|
||||||
|
}
|
||||||
|
defaults.set(data, forKey: queueKey)
|
||||||
|
} catch {
|
||||||
|
print("[SpeechQueue] Fehler beim Speichern der Queue:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadQueue() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let data = defaults.data(forKey: queueKey) {
|
||||||
|
do {
|
||||||
|
let savedQueue = try JSONDecoder().decode([SpeechQueueItem].self, from: data)
|
||||||
|
queue = savedQueue
|
||||||
|
print("[SpeechQueue] Queue geladen (", queue.count, ")")
|
||||||
|
} catch {
|
||||||
|
print("[SpeechQueue] Fehler beim Laden der Queue:", error)
|
||||||
|
defaults.removeObject(forKey: queueKey)
|
||||||
|
queue = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if queue.isEmpty {
|
||||||
|
print("[SpeechQueue] Queue ist nach dem Laden leer!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
readeck/UI/Utils/StringExtensions.swift
Normal file
29
readeck/UI/Utils/StringExtensions.swift
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var stripHTML: String {
|
||||||
|
// Entfernt HTML-Tags und decodiert HTML-Entities
|
||||||
|
let attributedString = try? NSAttributedString(
|
||||||
|
data: Data(utf8),
|
||||||
|
options: [
|
||||||
|
.documentType: NSAttributedString.DocumentType.html,
|
||||||
|
.characterEncoding: String.Encoding.utf8.rawValue
|
||||||
|
],
|
||||||
|
documentAttributes: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
return attributedString?.string ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
var stripHTMLSimple: String {
|
||||||
|
// Einfache Regex-basierte HTML-Entfernung
|
||||||
|
return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
|
||||||
|
.replacingOccurrences(of: " ", with: " ")
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
.replacingOccurrences(of: """, with: "\"")
|
||||||
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
179
readeck/UI/Utils/TTSManager.swift
Normal file
179
readeck/UI/Utils/TTSManager.swift
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
|
||||||
|
static let shared = TTSManager()
|
||||||
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
|
private let voiceManager = VoiceManager.shared
|
||||||
|
|
||||||
|
@Published var isSpeaking = false
|
||||||
|
@Published var currentUtterance = ""
|
||||||
|
@Published var progress: Double = 0.0
|
||||||
|
@Published var totalUtterances: Int = 0
|
||||||
|
@Published var currentUtteranceIndex: Int = 0
|
||||||
|
@Published var articleProgress: Double = 0.0
|
||||||
|
@Published var volume: Float = 1.0
|
||||||
|
@Published var rate: Float = 0.5
|
||||||
|
|
||||||
|
override private init() {
|
||||||
|
super.init()
|
||||||
|
synthesizer.delegate = self
|
||||||
|
configureAudioSession()
|
||||||
|
loadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAudioSession() {
|
||||||
|
do {
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
try audioSession.setActive(true)
|
||||||
|
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleAppDidEnterBackground),
|
||||||
|
name: UIApplication.didEnterBackgroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleAppWillEnterForeground),
|
||||||
|
name: UIApplication.willEnterForegroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
print("Fehler beim Konfigurieren der Audio-Session: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func speak(text: String, language: String = "de-DE", utteranceIndex: Int = 0, totalUtterances: Int = 1) {
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isSpeaking = true
|
||||||
|
self.currentUtterance = text
|
||||||
|
self.currentUtteranceIndex = utteranceIndex
|
||||||
|
self.totalUtterances = totalUtterances
|
||||||
|
self.updateProgress()
|
||||||
|
self.articleProgress = 0.0
|
||||||
|
}
|
||||||
|
if synthesizer.isSpeaking {
|
||||||
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
|
}
|
||||||
|
let utterance = AVSpeechUtterance(string: text)
|
||||||
|
utterance.voice = voiceManager.getVoice(for: language)
|
||||||
|
utterance.rate = rate
|
||||||
|
utterance.pitchMultiplier = 1.0
|
||||||
|
utterance.volume = volume
|
||||||
|
synthesizer.speak(utterance)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress() {
|
||||||
|
if totalUtterances > 0 {
|
||||||
|
progress = Double(currentUtteranceIndex) / Double(totalUtterances)
|
||||||
|
} else {
|
||||||
|
progress = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(_ newVolume: Float) {
|
||||||
|
volume = newVolume
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRate(_ newRate: Float) {
|
||||||
|
rate = newRate
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSettings() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let savedVolume = defaults.value(forKey: "tts_volume") as? Float {
|
||||||
|
volume = savedVolume
|
||||||
|
}
|
||||||
|
if let savedRate = defaults.value(forKey: "tts_rate") as? Float {
|
||||||
|
rate = savedRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSettings() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(volume, forKey: "tts_volume")
|
||||||
|
defaults.set(rate, forKey: "tts_rate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
synthesizer.pauseSpeaking(at: .immediate)
|
||||||
|
isSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
synthesizer.continueSpeaking()
|
||||||
|
isSpeaking = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
|
isSpeaking = false
|
||||||
|
currentUtterance = ""
|
||||||
|
articleProgress = 0.0
|
||||||
|
updateProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||||
|
isSpeaking = false
|
||||||
|
currentUtterance = ""
|
||||||
|
currentUtteranceIndex += 1
|
||||||
|
updateProgress()
|
||||||
|
articleProgress = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||||
|
isSpeaking = false
|
||||||
|
currentUtterance = ""
|
||||||
|
articleProgress = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
|
||||||
|
isSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
|
||||||
|
isSpeaking = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
|
||||||
|
let total = utterance.speechString.count
|
||||||
|
if total > 0 {
|
||||||
|
let spoken = characterRange.location + characterRange.length
|
||||||
|
let progress = min(Double(spoken) / Double(total), 1.0)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.articleProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrentlySpeaking() -> Bool {
|
||||||
|
return synthesizer.isSpeaking
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleAppDidEnterBackground() {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} catch {
|
||||||
|
print("Fehler beim Aktivieren der Audio-Session im Hintergrund: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleAppWillEnterForeground() {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} catch {
|
||||||
|
print("Fehler beim Aktivieren der Audio-Session im Vordergrund: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
124
readeck/UI/Utils/VoiceManager.swift
Normal file
124
readeck/UI/Utils/VoiceManager.swift
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
class VoiceManager: ObservableObject {
|
||||||
|
static let shared = VoiceManager()
|
||||||
|
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let selectedVoiceKey = "selectedVoice"
|
||||||
|
private var cachedVoices: [String: AVSpeechSynthesisVoice] = [:]
|
||||||
|
|
||||||
|
@Published var selectedVoice: AVSpeechSynthesisVoice?
|
||||||
|
@Published var availableVoices: [AVSpeechSynthesisVoice] = []
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadAvailableVoices()
|
||||||
|
loadSelectedVoice()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
func getVoice(for language: String = "de-DE") -> AVSpeechSynthesisVoice {
|
||||||
|
// Verwende ausgewählte Stimme falls verfügbar
|
||||||
|
if let selected = selectedVoice, selected.language == language {
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verwende gecachte Stimme
|
||||||
|
if let cachedVoice = cachedVoices[language] {
|
||||||
|
return cachedVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finde und cache eine neue Stimme
|
||||||
|
let voice = findEnhancedVoice(for: language)
|
||||||
|
cachedVoices[language] = voice
|
||||||
|
return voice
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSelectedVoice(_ voice: AVSpeechSynthesisVoice) {
|
||||||
|
selectedVoice = voice
|
||||||
|
saveSelectedVoice(voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAvailableVoices(for language: String = "de-DE") -> [AVSpeechSynthesisVoice] {
|
||||||
|
return availableVoices.filter { $0.language == language }
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPreferredVoices(for language: String = "de-DE") -> [AVSpeechSynthesisVoice] {
|
||||||
|
let preferredVoiceNames = [
|
||||||
|
"Anna", // Deutsche Premium-Stimme
|
||||||
|
"Helena", // Deutsche Premium-Stimme
|
||||||
|
"Siri", // Siri-Stimme (falls verfügbar)
|
||||||
|
"Enhanced", // Enhanced-Stimmen
|
||||||
|
"Karen", // Englische Premium-Stimme
|
||||||
|
"Daniel", // Englische Premium-Stimme
|
||||||
|
"Marie", // Französische Premium-Stimme
|
||||||
|
"Paolo", // Italienische Premium-Stimme
|
||||||
|
"Carmen", // Spanische Premium-Stimme
|
||||||
|
"Yuki" // Japanische Premium-Stimme
|
||||||
|
]
|
||||||
|
|
||||||
|
var preferredVoices: [AVSpeechSynthesisVoice] = []
|
||||||
|
|
||||||
|
for voiceName in preferredVoiceNames {
|
||||||
|
if let voice = availableVoices.first(where: {
|
||||||
|
$0.language == language &&
|
||||||
|
$0.name.contains(voiceName)
|
||||||
|
}) {
|
||||||
|
preferredVoices.append(voice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredVoices
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func loadAvailableVoices() {
|
||||||
|
availableVoices = AVSpeechSynthesisVoice.speechVoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSelectedVoice() {
|
||||||
|
if let voiceIdentifier = userDefaults.string(forKey: selectedVoiceKey),
|
||||||
|
let voice = availableVoices.first(where: { $0.identifier == voiceIdentifier }) {
|
||||||
|
selectedVoice = voice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSelectedVoice(_ voice: AVSpeechSynthesisVoice) {
|
||||||
|
userDefaults.set(voice.identifier, forKey: selectedVoiceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findEnhancedVoice(for language: String) -> AVSpeechSynthesisVoice {
|
||||||
|
// Zuerst nach bevorzugten Stimmen für die spezifische Sprache suchen
|
||||||
|
let preferredVoices = getPreferredVoices(for: language)
|
||||||
|
if let preferredVoice = preferredVoices.first {
|
||||||
|
return preferredVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Erste verfügbare Stimme für die Sprache
|
||||||
|
return availableVoices.first(where: { $0.language == language }) ??
|
||||||
|
AVSpeechSynthesisVoice(language: language) ??
|
||||||
|
AVSpeechSynthesisVoice()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Methods
|
||||||
|
|
||||||
|
func printAvailableVoices(for language: String = "de-DE") {
|
||||||
|
let filteredVoices = availableVoices.filter { $0.language.starts(with: language.prefix(2)) }
|
||||||
|
|
||||||
|
print("Verfügbare Stimmen für \(language):")
|
||||||
|
for voice in filteredVoices {
|
||||||
|
print("- \(voice.name) (\(voice.language)) - Qualität: \(voice.quality.rawValue)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printAllAvailableLanguages() {
|
||||||
|
let languages = Set(availableVoices.map { $0.language })
|
||||||
|
|
||||||
|
print("Verfügbare Sprachen:")
|
||||||
|
for language in languages.sorted() {
|
||||||
|
print("- \(language)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,9 +24,6 @@ struct readeckApp: App {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
|
||||||
handleIncomingURL(url)
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
NFX.sharedInstance().start()
|
NFX.sharedInstance().start()
|
||||||
@ -43,30 +40,4 @@ struct readeckApp: App {
|
|||||||
let settingsRepository = SettingsRepository()
|
let settingsRepository = SettingsRepository()
|
||||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleIncomingURL(_ url: URL) {
|
|
||||||
guard url.scheme == "readeck",
|
|
||||||
url.host == "add-bookmark" else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
|
|
||||||
let queryItems = components?.queryItems
|
|
||||||
|
|
||||||
let urlToAdd = queryItems?.first(where: { $0.name == "url" })?.value
|
|
||||||
let title = queryItems?.first(where: { $0.name == "title" })?.value
|
|
||||||
let notes = queryItems?.first(where: { $0.name == "notes" })?.value
|
|
||||||
|
|
||||||
// Öffne AddBookmarkView mit den Daten
|
|
||||||
// Hier kannst du eine Notification posten oder einen State ändern
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: NSNotification.Name("AddBookmarkFromShare"),
|
|
||||||
object: nil,
|
|
||||||
userInfo: [
|
|
||||||
"url": urlToAdd ?? "",
|
|
||||||
"title": title ?? "",
|
|
||||||
"notes": notes ?? ""
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
158
readeckTests/StringExtensionsTests.swift
Normal file
158
readeckTests/StringExtensionsTests.swift
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import readeck
|
||||||
|
|
||||||
|
final class StringExtensionsTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - stripHTML Tests
|
||||||
|
|
||||||
|
func testStripHTML_SimpleTags() {
|
||||||
|
let html = "<p>Dies ist ein <strong>wichtiger</strong> Artikel.</p>"
|
||||||
|
let expected = "Dies ist ein wichtiger Artikel.\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_ComplexNestedTags() {
|
||||||
|
let html = "<div><h1>Titel</h1><p>Text mit <em>kursiv</em> und <strong>fett</strong>.</p></div>"
|
||||||
|
let expected = "Titel\nText mit kursiv und fett."
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_WithAttributes() {
|
||||||
|
let html = "<p class=\"important\" id=\"main\">Text mit Attributen</p>"
|
||||||
|
let expected = "Text mit Attributen\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_EmptyTags() {
|
||||||
|
let html = "<p></p><div>Inhalt</div><span></span>"
|
||||||
|
let expected = "\nInhalt\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_SelfClosingTags() {
|
||||||
|
let html = "<p>Text mit <br>Zeilenumbruch und <img src=\"test.jpg\"> Bild.</p>"
|
||||||
|
let expected = "Text mit \nZeilenumbruch und Bild.\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_NoTags() {
|
||||||
|
let plainText = "Dies ist normaler Text ohne HTML."
|
||||||
|
|
||||||
|
XCTAssertEqual(plainText.stripHTML, plainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_EmptyString() {
|
||||||
|
let emptyString = ""
|
||||||
|
|
||||||
|
XCTAssertEqual(emptyString.stripHTML, emptyString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_OnlyTags() {
|
||||||
|
let onlyTags = "<p><div><span></span></div></p>"
|
||||||
|
let expected = "\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(onlyTags.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - stripHTMLSimple Tests
|
||||||
|
|
||||||
|
func testStripHTMLSimple_BasicTags() {
|
||||||
|
let html = "<p>Text mit <strong>fett</strong>.</p>"
|
||||||
|
let expected = "Text mit fett."
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_HTMLEntities() {
|
||||||
|
let html = "<p>Text mit Leerzeichen, & Zeichen und "Anführungszeichen".</p>"
|
||||||
|
let expected = "Text mit Leerzeichen, & Zeichen und \"Anführungszeichen\"."
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_MoreEntities() {
|
||||||
|
let html = "<p><Tag> und 'Apostroph'</p>"
|
||||||
|
let expected = "<Tag> und 'Apostroph'"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_ComplexHTML() {
|
||||||
|
let html = "<div class=\"container\"><h1>Überschrift</h1><p>Absatz mit <em>kursiv</em> und <strong>fett</strong>.</p><ul><li>Liste 1</li><li>Liste 2</li></ul></div>"
|
||||||
|
let expected = "Überschrift\nAbsatz mit kursiv und fett.\nListe 1\nListe 2"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_NoTags() {
|
||||||
|
let plainText = "Normaler Text ohne HTML."
|
||||||
|
|
||||||
|
XCTAssertEqual(plainText.stripHTMLSimple, plainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_EmptyString() {
|
||||||
|
let emptyString = ""
|
||||||
|
|
||||||
|
XCTAssertEqual(emptyString.stripHTMLSimple, emptyString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_WhitespaceHandling() {
|
||||||
|
let html = " <p> Text mit Whitespace </p> "
|
||||||
|
let expected = "Text mit Whitespace"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Performance Tests
|
||||||
|
|
||||||
|
func testStripHTML_Performance() {
|
||||||
|
let largeHTML = String(repeating: "<p>Dies ist ein Test mit <strong>vielen</strong> <em>HTML</em> Tags.</p>", count: 1000)
|
||||||
|
|
||||||
|
measure {
|
||||||
|
_ = largeHTML.stripHTML
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_Performance() {
|
||||||
|
let largeHTML = String(repeating: "<p>Dies ist ein Test mit <strong>vielen</strong> <em>HTML</em> Tags.</p>", count: 1000)
|
||||||
|
|
||||||
|
measure {
|
||||||
|
_ = largeHTML.stripHTMLSimple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edge Cases
|
||||||
|
|
||||||
|
func testStripHTML_MalformedHTML() {
|
||||||
|
let malformed = "<p>Unvollständiger <strong>Tag"
|
||||||
|
let expected = "Unvollständiger Tag"
|
||||||
|
|
||||||
|
XCTAssertEqual(malformed.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_UnicodeCharacters() {
|
||||||
|
let html = "<p>Text mit Umlauten: äöüß und Emojis: 🚀📱</p>"
|
||||||
|
let expected = "Text mit Umlauten: äöüß und Emojis: 🚀📱"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_Newlines() {
|
||||||
|
let html = "<p>Erste Zeile<br>Zweite Zeile</p>"
|
||||||
|
let expected = "Erste Zeile\nZweite Zeile"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_ListItems() {
|
||||||
|
let html = "<ul><li>Erster Punkt</li><li>Zweiter Punkt</li><li>Dritter Punkt</li></ul>"
|
||||||
|
let expected = "Erster Punkt\nZweiter Punkt\nDritter Punkt"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user