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