Compare commits
No commits in common. "e68959afcecedbca39aad853b5987c6abb41f34d" and "2c5b51ca3a1e0280a425b0897bb3647c0d218501" have entirely different histories.
e68959afce
...
2c5b51ca3a
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.fontSize": 14
|
|
||||||
}
|
|
||||||
@ -3,64 +3,31 @@
|
|||||||
"strings" : {
|
"strings" : {
|
||||||
"" : {
|
"" : {
|
||||||
|
|
||||||
},
|
|
||||||
"%@ (%lld)" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "new",
|
|
||||||
"value" : "%1$@ (%2$lld)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"%lld" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"%lld Artikel in der Queue" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld min" : {
|
"%lld min" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld Minuten" : {
|
"%lld Minuten" : {
|
||||||
|
|
||||||
},
|
|
||||||
"%lld." : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"%lld/%lld" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "new",
|
|
||||||
"value" : "%1$lld/%2$lld"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"12 min • Today • example.com" : {
|
"12 min • Today • example.com" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Abbrechen" : {
|
"Abbrechen" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "needs_review",
|
||||||
|
"value" : "Abbrechen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Abmelden" : {
|
"Abmelden" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Aktuelle Labels" : {
|
"Add Item" : {
|
||||||
|
|
||||||
},
|
|
||||||
"all" : {
|
|
||||||
"extractionState" : "manual",
|
|
||||||
"localizations" : {
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Ale"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"Anmelden & speichern" : {
|
"Anmelden & speichern" : {
|
||||||
|
|
||||||
@ -70,9 +37,6 @@
|
|||||||
},
|
},
|
||||||
"Artikel automatisch als gelesen markieren" : {
|
"Artikel automatisch als gelesen markieren" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Artikel vorlesen" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Automatischer Sync" : {
|
"Automatischer Sync" : {
|
||||||
|
|
||||||
@ -97,6 +61,17 @@
|
|||||||
},
|
},
|
||||||
"Debug-Anmeldung" : {
|
"Debug-Anmeldung" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"done" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Fertig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Einfügen" : {
|
"Einfügen" : {
|
||||||
|
|
||||||
@ -136,9 +111,6 @@
|
|||||||
},
|
},
|
||||||
"Fehler" : {
|
"Fehler" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Fehler: %@" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Fertig" : {
|
"Fertig" : {
|
||||||
|
|
||||||
@ -146,17 +118,22 @@
|
|||||||
"Fertig mit Lesen?" : {
|
"Fertig mit Lesen?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Fortschritt: %lld%%" : {
|
"font_settings_title" : {
|
||||||
|
"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" : {
|
||||||
|
|
||||||
@ -173,7 +150,7 @@
|
|||||||
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
|
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Keine Artikel in der Queue" : {
|
"Item at %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Keine Bookmarks" : {
|
"Keine Bookmarks" : {
|
||||||
@ -184,37 +161,15 @@
|
|||||||
},
|
},
|
||||||
"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" : {
|
||||||
|
|
||||||
@ -230,9 +185,6 @@
|
|||||||
},
|
},
|
||||||
"Neues Bookmark" : {
|
"Neues Bookmark" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Neues Label hinzufügen" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"OK" : {
|
"OK" : {
|
||||||
|
|
||||||
@ -264,7 +216,10 @@
|
|||||||
"Schriftgröße" : {
|
"Schriftgröße" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Select a bookmark or tag" : {
|
"Select a bookmark" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Select an item" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Server-Endpunkt" : {
|
"Server-Endpunkt" : {
|
||||||
@ -311,18 +266,12 @@
|
|||||||
},
|
},
|
||||||
"Version %@" : {
|
"Version %@" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Vorlese-Queue" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Vorschau" : {
|
"Vorschau" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Website" : {
|
"Website" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Weiterhören" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Wiederherstellen" : {
|
"Wiederherstellen" : {
|
||||||
|
|
||||||
|
|||||||
@ -319,6 +319,7 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"fr-CA",
|
||||||
de,
|
de,
|
||||||
);
|
);
|
||||||
mainGroup = 5D45F9BF2DF858680048D5B8;
|
mainGroup = 5D45F9BF2DF858680048D5B8;
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"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,14 +10,13 @@ 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]?, tag: String?) async throws -> BookmarksPageDto
|
func getBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) 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 {
|
||||||
@ -181,12 +180,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, tag: String? = nil) async throws -> BookmarksPageDto {
|
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = 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 {
|
if let state = 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"))
|
||||||
@ -200,28 +199,24 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let limit {
|
if let limit = limit {
|
||||||
queryItems.append(URLQueryItem(name: "limit", value: "\(limit)"))
|
queryItems.append(URLQueryItem(name: "limit", value: "\(limit)"))
|
||||||
}
|
}
|
||||||
if let offset {
|
if let offset = offset {
|
||||||
queryItems.append(URLQueryItem(name: "offset", value: "\(offset)"))
|
queryItems.append(URLQueryItem(name: "offset", value: "\(offset)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let search {
|
if let search = 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.isEmpty {
|
if let type = 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)"
|
||||||
@ -355,13 +350,6 @@ 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 {
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
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,9 +71,3 @@ 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,5 +1,15 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -7,8 +17,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, tag: String? = nil) async throws -> BookmarksPage {
|
func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage {
|
||||||
let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type, tag: tag)
|
let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type)
|
||||||
return bookmarkDtos.toDomain()
|
return bookmarkDtos.toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,10 +38,8 @@ 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 ?? ""
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
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,11 +14,8 @@ 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 {
|
||||||
@ -36,9 +33,7 @@ extension BookmarkDetail {
|
|||||||
hasArticle: false,
|
hasArticle: false,
|
||||||
isMarked: false,
|
isMarked: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
labels: [],
|
|
||||||
thumbnailUrl: "",
|
thumbnailUrl: "",
|
||||||
imageUrl: "",
|
imageUrl: ""
|
||||||
lang: ""
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
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,12 +59,4 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,16 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
protocol PLabelsRepository {
|
|
||||||
func getLabels() async throws -> [BookmarkLabel]
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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, tag: String? = nil) async throws -> BookmarksPage {
|
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage {
|
||||||
var allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search, type: type, tag: tag)
|
var allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search, type: type)
|
||||||
|
|
||||||
if let state = state {
|
if let state = state {
|
||||||
allBookmarks.bookmarks = allBookmarks.bookmarks.filter { bookmark in
|
allBookmarks.bookmarks = allBookmarks.bookmarks.filter { bookmark in
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
class GetLabelsUseCase {
|
|
||||||
private let labelsRepository: PLabelsRepository
|
|
||||||
|
|
||||||
init(labelsRepository: PLabelsRepository) {
|
|
||||||
self.labelsRepository = labelsRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute() async throws -> [BookmarkLabel] {
|
|
||||||
return try await labelsRepository.getLabels()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
class ReadBookmarkUseCase {
|
|
||||||
private let addToSpeechQueue: AddTextToSpeechQueueUseCase
|
|
||||||
|
|
||||||
init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) {
|
|
||||||
self.addToSpeechQueue = addToSpeechQueue
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute(bookmarkDetail: BookmarkDetail) {
|
|
||||||
addToSpeechQueue.execute(bookmarkDetail: bookmarkDetail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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,14 +41,4 @@ 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,13 +16,9 @@
|
|||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
<string>green2</string>
|
<string>green</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,10 +6,8 @@ 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 = 320
|
private let headerHeight: CGFloat = 260
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
@ -31,18 +29,10 @@ struct BookmarkDetailView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: 12) {
|
Button(action: {
|
||||||
Button(action: {
|
showingFontSettings = true
|
||||||
showingLabelsSheet = true
|
}) {
|
||||||
}) {
|
Image(systemName: "textformat")
|
||||||
Image(systemName: "tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showingFontSettings = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "textformat")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,9 +57,6 @@ 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
|
||||||
@ -78,14 +65,6 @@ 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)
|
||||||
@ -112,22 +91,13 @@ 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: [
|
gradient: Gradient(colors: [Color.black.opacity(0.6), Color.clear]),
|
||||||
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: 240)
|
.frame(height: 120)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.offset(y: (offset > 0 ? -offset : 0))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: headerHeight)
|
.frame(height: headerHeight)
|
||||||
@ -185,38 +155,6 @@ 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)
|
||||||
@ -226,20 +164,10 @@ 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,7 +6,6 @@ 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 = ""
|
||||||
@ -23,7 +22,6 @@ 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
|
||||||
@ -75,14 +73,4 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
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"])
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
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,17 +15,8 @@ struct BookmarksView: View {
|
|||||||
|
|
||||||
let state: BookmarkState
|
let state: BookmarkState
|
||||||
let type: [BookmarkType]
|
let type: [BookmarkType]
|
||||||
@Binding var selectedBookmark: Bookmark?
|
|
||||||
@EnvironmentObject var playerUIState: PlayerUIState
|
|
||||||
let tag: String?
|
|
||||||
|
|
||||||
// MARK: Initializer
|
@Binding var selectedBookmark: Bookmark?
|
||||||
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
|
||||||
|
|
||||||
@ -156,7 +147,7 @@ struct BookmarksView: View {
|
|||||||
}*/
|
}*/
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
await viewModel.loadBookmarks(state: state, type: type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: showingAddBookmark) { oldValue, newValue in
|
.onChange(of: showingAddBookmark) { oldValue, newValue in
|
||||||
|
|||||||
@ -13,13 +13,11 @@ 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
|
||||||
@ -76,12 +74,11 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
|
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article]) 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
|
||||||
@ -92,11 +89,10 @@ 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.currentPage != newBookmarks.totalPages // Prüfen, ob weitere Daten verfügbar sind
|
hasMoreData = newBookmarks.bookmarks.count == limit // 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
|
||||||
@ -118,10 +114,9 @@ class BookmarksViewModel {
|
|||||||
state: currentState,
|
state: currentState,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
type: currentType,
|
type: currentType)
|
||||||
tag: currentTag)
|
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) // Neue Bookmarks hinzufügen
|
||||||
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
|
hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen,
|
||||||
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Fehler beim Nachladen der Bookmarks"
|
errorMessage = "Fehler beim Nachladen der Bookmarks"
|
||||||
}
|
}
|
||||||
|
|||||||
88
readeck/UI/ContentView.swift
Normal file
88
readeck/UI/ContentView.swift
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// 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,10 +13,6 @@ 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 {
|
||||||
@ -82,22 +78,4 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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,8 +10,6 @@ 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]
|
||||||
|
|
||||||
@ -21,8 +19,6 @@ 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)
|
||||||
@ -35,6 +31,16 @@ 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))
|
||||||
@ -43,6 +49,7 @@ 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
|
||||||
}) {
|
}) {
|
||||||
@ -53,45 +60,43 @@ 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: {
|
||||||
GlobalPlayerContainerView {
|
Group {
|
||||||
Group {
|
switch selectedTab {
|
||||||
switch selectedTab {
|
case .search:
|
||||||
case .search:
|
SearchBookmarksView(selectedBookmark: $selectedBookmark)
|
||||||
SearchBookmarksView(selectedBookmark: $selectedBookmark)
|
case .all:
|
||||||
case .all:
|
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
case .unread:
|
||||||
case .unread:
|
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
|
case .favorite:
|
||||||
case .favorite:
|
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
|
case .archived:
|
||||||
case .archived:
|
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
|
case .settings:
|
||||||
case .settings:
|
SettingsView()
|
||||||
SettingsView()
|
case .article:
|
||||||
case .article:
|
BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark)
|
case .videos:
|
||||||
case .videos:
|
BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark)
|
case .pictures:
|
||||||
case .pictures:
|
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
|
||||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
|
case .tags:
|
||||||
case .tags:
|
Text("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 or tag")
|
Text(selectedTab == .settings ? "" : "Select a bookmark")
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,54 +15,42 @@ struct PhoneTabView: View {
|
|||||||
@State private var selectedTabIndex: Int = 0
|
@State private var selectedTabIndex: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GlobalPlayerContainerView {
|
TabView(selection: $selectedTabIndex) {
|
||||||
TabView(selection: $selectedTabIndex) {
|
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
|
||||||
NavigationStack {
|
|
||||||
tabView(for: tab)
|
|
||||||
}
|
|
||||||
.tabItem {
|
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
|
||||||
}
|
|
||||||
.tag(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if let selectedTab = selectedMoreTab {
|
tabView(for: tab)
|
||||||
tabView(for: selectedTab)
|
|
||||||
.navigationTitle(selectedTab.label)
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
|
||||||
NavigationLink {
|
|
||||||
tabView(for: tab)
|
|
||||||
.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 {
|
.tabItem {
|
||||||
Label("Mehr", systemImage: "ellipsis")
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
}
|
}
|
||||||
.tag(mainTabs.count)
|
.tag(idx)
|
||||||
.onAppear {
|
}
|
||||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
|
||||||
selectedMoreTab = nil
|
NavigationStack {
|
||||||
|
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
||||||
|
NavigationLink(tag: tab, selection: $selectedMoreTab) {
|
||||||
|
tabView(for: tab)
|
||||||
|
.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))
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Mehr", systemImage: "ellipsis")
|
||||||
|
}
|
||||||
|
.tag(mainTabs.count)
|
||||||
|
.onAppear {
|
||||||
|
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||||
|
selectedMoreTab = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accentColor(.accentColor)
|
|
||||||
}
|
}
|
||||||
|
.accentColor(.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -87,7 +75,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:
|
||||||
LabelsView()
|
Text("Tags")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
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 "All"
|
case .all: return "Alle"
|
||||||
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,7 +4,6 @@ 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)
|
||||||
@ -16,10 +15,8 @@ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
class PlayerUIState: ObservableObject {
|
|
||||||
@Published var isPlayerVisible: Bool = false
|
|
||||||
|
|
||||||
func showPlayer() {
|
|
||||||
isPlayerVisible = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func hidePlayer() {
|
|
||||||
isPlayerVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func togglePlayer() {
|
|
||||||
isPlayerVisible.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,303 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
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!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
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,6 +24,9 @@ struct readeckApp: App {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onOpenURL { url in
|
||||||
|
handleIncomingURL(url)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
NFX.sharedInstance().start()
|
NFX.sharedInstance().start()
|
||||||
@ -40,4 +43,30 @@ 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 ?? ""
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
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