Compare commits

...

5 Commits

Author SHA1 Message Date
e68959afce Refactor: Move Utils to UI/Utils, improve SpeechPlayer UI, enhance state management, remove legacy files, and optimize queue handling
- Move and replace utility files (SafariUtil, SpeechQueue, StringExtensions, TTSManager, VoiceManager)
- Refactor and extend SpeechPlayer components (UI, progress, volume, queue)
- Improved state and EnvironmentObject management (PlayerUIState)
- UI and logic optimizations in menu and tab views
- Remove obsolete and duplicate files
- General code and UX improvements
2025-07-14 21:34:39 +02:00
9b89e58115 Refactor TTS system with Combine and VoiceManager
- Replace @MainActor with Combine framework for TTS functionality
- Create VoiceManager class for voice selection and caching
- Add UserDefaults persistence for selected voice
- Optimize performance with voice caching and immediate UI updates
- Remove @MainActor from TTS-related Use Cases
- Add proper pause/resume delegate methods
- Improve reactive UI updates with @StateObject
- Clean up code and remove unnecessary comments
2025-07-09 23:15:23 +02:00
09f1ddea58 Add text-to-speech functionality
- Add TTSManager and SpeechQueue utilities
- Create AddTextToSpeechQueueUseCase and ReadBookmarkUseCase
- Add SpeechPlayer UI components (GlobalPlayerContainerView, SpeechPlayerView, SpeechPlayerViewModel)
- Update BookmarkDetailView and BookmarkDetailViewModel for TTS integration
- Add audio background mode to Info.plist
- Update PhoneTabView for TTS controls
- Add StringExtensions for text processing
- Add StringExtensionsTests for testing
- Update Localizable.xcstrings with new strings
- Add VS Code settings
2025-07-09 22:31:17 +02:00
3e6db364b5 Add bookmark labels functionality
- Add BookmarkLabel model and DTO
- Create LabelsRepository and PLabelsRepository protocol
- Add GetLabelsUseCase for fetching labels
- Update BookmarkMapper to handle labels
- Add LabelsView and LabelsViewModel for UI
- Update BookmarksView and BookmarkLabelsView to display labels
- Add green2 color asset for labels
- Update API and repository layers to support labels
2025-07-09 22:28:19 +02:00
d2e8228903 feat: Add label management to bookmarks and UI improvements
- BookmarkDetail: Add labels property, display and manage labels in detail view
- Add AddLabelsToBookmarkUseCase and RemoveLabelsFromBookmarkUseCase
- Update UpdateBookmarkUseCase and BookmarkUpdateRequest for label operations
- UI: Show labels in BookmarkDetailView, add label management sheet
- DefaultUseCaseFactory: Provide use cases for label management
- Localizable: Add/adjust label-related strings, minor cleanup
- SettingsServerView: Update debug endpoint
- SidebarTab: Change 'Alle' to 'All'
- Project: Remove unused region from Xcode project
2025-07-08 16:30:27 +02:00
49 changed files with 2062 additions and 259 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.fontSize": 14
}

View File

@ -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" : {

View File

@ -319,7 +319,6 @@
knownRegions = (
en,
Base,
"fr-CA",
de,
);
mainGroup = 5D45F9BF2DF858680048D5B8;

View 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
}
}

View File

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

View File

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

View File

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

View File

@ -1,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 ?? ""
)
}

View File

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

View File

@ -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: ""
)
}

View File

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

View File

@ -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)
}
}

View 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
}

View File

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

View 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"
}
}
}

View 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))
}
}

View File

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

View File

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

View File

@ -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)
}
}

View 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])
}
}

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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 {

View File

@ -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)
}
}

View 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"])
}

View 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
}
}

View File

@ -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

View File

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

View File

@ -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)
}

View File

@ -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()
}
}

View 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()
}
}
}
}

View File

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

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View 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)
}
}
}

View File

@ -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"

View File

@ -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)
}
}
}

View 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())
}

View 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()
}
}

View 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()
}

View 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()
}
}

View 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!")
}
}
}

View 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: "&nbsp;", with: " ")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", with: "'")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View 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)
}
}

View 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)")
}
}
}

View File

@ -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 ?? ""
]
)
}
}

View 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 &nbsp;Leerzeichen, &amp; Zeichen und &quot;Anführungszeichen&quot;.</p>"
let expected = "Text mit Leerzeichen, & Zeichen und \"Anführungszeichen\"."
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_MoreEntities() {
let html = "<p>&lt;Tag&gt; und &#39;Apostroph&#39;</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)
}
}