Add TTS feature toggle, refactor settings, and improve UI
- Implemented a toggle for the 'Read Aloud' (TTS) feature in the general settings. - Refactored AppSettings and PlayerUIState to support TTS enable/disable. - Updated BookmarkDetailView, PadSidebarView, PhoneTabView, and GlobalPlayerContainerView to respect the TTS setting. - Added new RButton component for consistent button styling. - Improved LabelsView to support tag selection on iPad and iPhone. - Updated SettingsGeneralView and SettingsGeneralViewModel for new TTS logic and removed unused app info code. - Added app info section to SettingsContainerView. - Updated SettingsServerView to use English labels and messages. - Refactored SpeechPlayerViewModel to only initialize TTS when enabled. - Updated Core Data model to include enableTTS in SettingEntity. - Removed obsolete files (PersistenceController.swift, old PlayerUIState). - Various bugfixes, code cleanups, and UI improvements.
This commit is contained in:
parent
387a026e7d
commit
8d4b08da11
@ -42,19 +42,13 @@
|
||||
"12 min • Today • example.com" : {
|
||||
|
||||
},
|
||||
"Abbrechen" : {
|
||||
|
||||
},
|
||||
"Abmelden" : {
|
||||
|
||||
},
|
||||
"About the App" : {
|
||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." : {
|
||||
|
||||
},
|
||||
"Add a new link to your collection" : {
|
||||
|
||||
},
|
||||
"Aktuelle Labels" : {
|
||||
"Add new label" : {
|
||||
|
||||
},
|
||||
"all" : {
|
||||
@ -67,15 +61,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Anmelden & speichern" : {
|
||||
|
||||
},
|
||||
"Archive" : {
|
||||
|
||||
},
|
||||
"Archive bookmark" : {
|
||||
|
||||
},
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
||||
|
||||
},
|
||||
"Automatic sync" : {
|
||||
|
||||
@ -94,6 +88,9 @@
|
||||
},
|
||||
"Close" : {
|
||||
|
||||
},
|
||||
"Current labels" : {
|
||||
|
||||
},
|
||||
"Data Management" : {
|
||||
|
||||
@ -101,7 +98,7 @@
|
||||
"Delete" : {
|
||||
|
||||
},
|
||||
"Developer: %@" : {
|
||||
"Developer: Ilyas Hallak" : {
|
||||
|
||||
},
|
||||
"Done" : {
|
||||
@ -110,10 +107,10 @@
|
||||
"e.g. work, important, later" : {
|
||||
|
||||
},
|
||||
"Erfolgreich angemeldet" : {
|
||||
"Enter label..." : {
|
||||
|
||||
},
|
||||
"Erneut anmelden & speichern" : {
|
||||
"Enter your Readeck server details to get started." : {
|
||||
|
||||
},
|
||||
"Error" : {
|
||||
@ -124,12 +121,6 @@
|
||||
},
|
||||
"Favorite" : {
|
||||
|
||||
},
|
||||
"Fehler" : {
|
||||
|
||||
},
|
||||
"Fertig" : {
|
||||
|
||||
},
|
||||
"Finished reading?" : {
|
||||
|
||||
@ -146,7 +137,10 @@
|
||||
"Font size" : {
|
||||
|
||||
},
|
||||
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
|
||||
"From Bremen with 💚" : {
|
||||
|
||||
},
|
||||
"General" : {
|
||||
|
||||
},
|
||||
"https://example.com" : {
|
||||
@ -154,48 +148,39 @@
|
||||
},
|
||||
"https://readeck.example.com" : {
|
||||
|
||||
},
|
||||
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
|
||||
|
||||
},
|
||||
"Keine Bookmarks gefunden." : {
|
||||
|
||||
},
|
||||
"Keine Ergebnisse" : {
|
||||
|
||||
},
|
||||
"Keine Labels vorhanden" : {
|
||||
|
||||
},
|
||||
"Key" : {
|
||||
"extractionState" : "manual"
|
||||
},
|
||||
"Label eingeben..." : {
|
||||
|
||||
},
|
||||
"Labels" : {
|
||||
|
||||
},
|
||||
"Labels verwalten" : {
|
||||
|
||||
},
|
||||
"Loading %@..." : {
|
||||
|
||||
},
|
||||
"Loading article..." : {
|
||||
|
||||
},
|
||||
"Login & Save" : {
|
||||
|
||||
},
|
||||
"Logout" : {
|
||||
|
||||
},
|
||||
"Manage Labels" : {
|
||||
|
||||
},
|
||||
"Mark as favorite" : {
|
||||
|
||||
},
|
||||
"Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen." : {
|
||||
|
||||
},
|
||||
"More" : {
|
||||
|
||||
},
|
||||
"Neues Label hinzufügen" : {
|
||||
|
||||
},
|
||||
"New Bookmark" : {
|
||||
|
||||
@ -208,6 +193,9 @@
|
||||
},
|
||||
"No bookmarks found in %@." : {
|
||||
|
||||
},
|
||||
"No labels available" : {
|
||||
|
||||
},
|
||||
"OK" : {
|
||||
|
||||
@ -229,6 +217,12 @@
|
||||
},
|
||||
"Progress: %lld%%" : {
|
||||
|
||||
},
|
||||
"Re-login & Save" : {
|
||||
|
||||
},
|
||||
"Read Aloud Feature" : {
|
||||
|
||||
},
|
||||
"Read article aloud" : {
|
||||
|
||||
@ -272,9 +266,6 @@
|
||||
},
|
||||
"Save bookmark" : {
|
||||
|
||||
},
|
||||
"Save settings" : {
|
||||
|
||||
},
|
||||
"Saving..." : {
|
||||
|
||||
@ -282,7 +273,7 @@
|
||||
"Select a bookmark or tag" : {
|
||||
|
||||
},
|
||||
"Server-Endpunkt" : {
|
||||
"Server Endpoint" : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
@ -291,7 +282,7 @@
|
||||
"Speed" : {
|
||||
|
||||
},
|
||||
"Speichern..." : {
|
||||
"Successfully logged in" : {
|
||||
|
||||
},
|
||||
"Suchbegriff eingeben..." : {
|
||||
@ -330,7 +321,7 @@
|
||||
"Version %@" : {
|
||||
|
||||
},
|
||||
"Website" : {
|
||||
"Your current server connection and login credentials." : {
|
||||
|
||||
},
|
||||
"Your Password" : {
|
||||
|
||||
@ -34,7 +34,7 @@ class ShareViewController: UIViewController {
|
||||
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
|
||||
|
||||
// Add cancel button
|
||||
let cancelButton = UIBarButtonItem(title: "Abbrechen", style: .plain, target: self, action: #selector(cancelButtonTapped))
|
||||
let cancelButton = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(cancelButtonTapped))
|
||||
cancelButton.tintColor = UIColor.white
|
||||
navigationItem.leftBarButtonItem = cancelButton
|
||||
|
||||
@ -54,14 +54,12 @@ class ShareViewController: UIViewController {
|
||||
// Add custom cancel button
|
||||
let customCancelButton = UIButton(type: .system)
|
||||
customCancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
customCancelButton.setTitle("Abbrechen", for: .normal)
|
||||
customCancelButton.setTitle("Cancel", for: .normal)
|
||||
customCancelButton.setTitleColor(UIColor.white, for: .normal)
|
||||
customCancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
|
||||
customCancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
|
||||
view.addSubview(customCancelButton)
|
||||
|
||||
|
||||
|
||||
// URL Container View
|
||||
let urlContainerView = UIView()
|
||||
urlContainerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
@ -79,7 +77,7 @@ class ShareViewController: UIViewController {
|
||||
urlLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
urlLabel?.textColor = UIColor.label
|
||||
urlLabel?.numberOfLines = 0
|
||||
urlLabel?.text = "URL wird geladen..."
|
||||
urlLabel?.text = "Loading URL..."
|
||||
urlLabel?.textAlignment = .left
|
||||
urlContainerView.addSubview(urlLabel!)
|
||||
|
||||
@ -97,7 +95,7 @@ class ShareViewController: UIViewController {
|
||||
// Title TextField
|
||||
titleTextField = UITextField()
|
||||
titleTextField?.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleTextField?.placeholder = "Optionales Titel eingeben..."
|
||||
titleTextField?.placeholder = "Enter an optional title..."
|
||||
titleTextField?.borderStyle = .none
|
||||
titleTextField?.font = UIFont.systemFont(ofSize: 16)
|
||||
titleTextField?.backgroundColor = UIColor.clear
|
||||
@ -114,13 +112,22 @@ class ShareViewController: UIViewController {
|
||||
statusLabel?.layer.masksToBounds = true
|
||||
view.addSubview(statusLabel!)
|
||||
|
||||
let isDarkMode = traitCollection.userInterfaceStyle == .dark
|
||||
|
||||
// Save Button
|
||||
saveButton = UIButton(type: .system)
|
||||
saveButton?.translatesAutoresizingMaskIntoConstraints = false
|
||||
saveButton?.setTitle("Bookmark speichern", for: .normal)
|
||||
saveButton?.setTitle("Save Bookmark", for: .normal)
|
||||
saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
|
||||
saveButton?.backgroundColor = UIColor.secondarySystemGroupedBackground
|
||||
saveButton?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal)
|
||||
|
||||
if isDarkMode {
|
||||
saveButton?.backgroundColor = UIColor(named: "green")
|
||||
saveButton?.layer.borderColor = UIColor(named: "green")?.cgColor
|
||||
} else {
|
||||
saveButton?.backgroundColor = .accent
|
||||
saveButton?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal)
|
||||
}
|
||||
|
||||
saveButton?.layer.cornerRadius = 16
|
||||
saveButton?.layer.shadowColor = UIColor.black.cgColor
|
||||
saveButton?.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
@ -129,7 +136,6 @@ class ShareViewController: UIViewController {
|
||||
saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
|
||||
view.addSubview(saveButton!)
|
||||
|
||||
|
||||
// Activity Indicator
|
||||
activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
activityIndicator?.translatesAutoresizingMaskIntoConstraints = false
|
||||
@ -262,29 +268,29 @@ class ShareViewController: UIViewController {
|
||||
// MARK: - API Call
|
||||
private func addBookmarkViaAPI(title: String) async {
|
||||
guard let url = extractedURL, !url.isEmpty else {
|
||||
showStatus("Keine URL gefunden.", error: true)
|
||||
showStatus("No URL found.", error: true)
|
||||
return
|
||||
}
|
||||
|
||||
// Token und Endpoint aus KeychainHelper
|
||||
guard let token = KeychainHelper.shared.loadToken() else {
|
||||
showStatus("Kein Token gefunden. Bitte in der Haupt-App einloggen.", error: true)
|
||||
showStatus("No token found. Please log in via the main app.", error: true)
|
||||
return
|
||||
}
|
||||
|
||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
|
||||
showStatus("Kein Server-Endpunkt gefunden.", error: true)
|
||||
showStatus("No server endpoint found.", error: true)
|
||||
return
|
||||
}
|
||||
|
||||
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
|
||||
guard let requestData = try? JSONEncoder().encode(requestDto) else {
|
||||
showStatus("Fehler beim Kodieren der Anfrage.", error: true)
|
||||
showStatus("Failed to encode request.", error: true)
|
||||
return
|
||||
}
|
||||
|
||||
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
|
||||
showStatus("Ungültiger Server-Endpunkt.", error: true)
|
||||
showStatus("Invalid server endpoint.", error: true)
|
||||
return
|
||||
}
|
||||
|
||||
@ -298,24 +304,24 @@ class ShareViewController: UIViewController {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
showStatus("Ungültige Server-Antwort.", error: true)
|
||||
showStatus("Invalid server response.", error: true)
|
||||
return
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unbekannter Fehler"
|
||||
showStatus("Serverfehler: \(httpResponse.statusCode)\n\(msg)", error: true)
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", error: true)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: Response parsen
|
||||
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
|
||||
showStatus("Gespeichert: \(resp.message)", error: false)
|
||||
showStatus("Saved: \(resp.message)", error: false)
|
||||
} else {
|
||||
showStatus("Lesezeichen gespeichert!", error: false)
|
||||
showStatus("Bookmark saved!", error: false)
|
||||
}
|
||||
} catch {
|
||||
showStatus("Netzwerkfehler: \(error.localizedDescription)", error: true)
|
||||
showStatus("Network error: \(error.localizedDescription)", error: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,7 +342,7 @@ class ShareViewController: UIViewController {
|
||||
}
|
||||
|
||||
|
||||
// MARK: - DTOs (kopiert)
|
||||
// MARK: - DTOs (copied)
|
||||
private struct CreateBookmarkRequestDto: Codable {
|
||||
let labels: [String]?
|
||||
let title: String?
|
||||
|
||||
@ -609,7 +609,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -653,7 +653,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
//
|
||||
// Persistence.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 10.06.25.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
@MainActor
|
||||
static let preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
for _ in 0..<10 {
|
||||
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)")
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
container = NSPersistentContainer(name: "readeck")
|
||||
if inMemory {
|
||||
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||
}
|
||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
||||
if let error = error as NSError? {
|
||||
// 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.
|
||||
|
||||
/*
|
||||
Typical reasons for an error here include:
|
||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
* The device is out of space.
|
||||
* The store could not be migrated to the current model version.
|
||||
Check the error message to determine what the actual problem was.
|
||||
*/
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ struct Settings {
|
||||
var fontFamily: FontFamily? = nil
|
||||
var fontSize: FontSize? = nil
|
||||
var hasFinishedSetup: Bool = false
|
||||
var enableTTS: Bool? = nil
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
token != nil && !token!.isEmpty
|
||||
@ -69,6 +70,9 @@ class SettingsRepository: PSettingsRepository {
|
||||
if let fontSize = settings.fontSize {
|
||||
existingSettings.fontSize = fontSize.rawValue
|
||||
}
|
||||
if let enableTTS = settings.enableTTS {
|
||||
existingSettings.enableTTS = enableTTS
|
||||
}
|
||||
|
||||
try context.save()
|
||||
}
|
||||
@ -99,7 +103,8 @@ class SettingsRepository: PSettingsRepository {
|
||||
password: settingEntity.password ?? "",
|
||||
token: settingEntity.token,
|
||||
fontFamily: FontFamily(rawValue: settingEntity.fontFamily ?? FontFamily.system.rawValue),
|
||||
fontSize: FontSize(rawValue: settingEntity.fontSize ?? FontSize.medium.rawValue)
|
||||
fontSize: FontSize(rawValue: settingEntity.fontSize ?? FontSize.medium.rawValue),
|
||||
enableTTS: settingEntity.enableTTS
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
} else {
|
||||
|
||||
21
readeck/Domain/Model/Theme.swift
Normal file
21
readeck/Domain/Model/Theme.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// Theme.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 21.07.25.
|
||||
//
|
||||
|
||||
|
||||
enum Theme: String, CaseIterable {
|
||||
case system = "system"
|
||||
case light = "light"
|
||||
case dark = "dark"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .system: return "System"
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ protocol PSaveSettingsUseCase {
|
||||
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws
|
||||
func execute(token: String) async throws
|
||||
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
|
||||
func execute(enableTTS: Bool) async throws
|
||||
}
|
||||
|
||||
class SaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
@ -51,4 +52,10 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func execute(enableTTS: Bool) async throws {
|
||||
try await settingsRepository.saveSettings(
|
||||
.init(enableTTS: enableTTS)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ struct BookmarkDetailView: View {
|
||||
@State private var showingFontSettings = false
|
||||
@State private var showingLabelsSheet = false
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
private let headerHeight: CGFloat = 320
|
||||
|
||||
@ -242,14 +243,16 @@ struct BookmarkDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
metaRow(icon: "speaker.wave.2") {
|
||||
Button(action: {
|
||||
viewModel.addBookmarkToSpeechQueue()
|
||||
playerUIState.showPlayer()
|
||||
}) {
|
||||
Text("Read article aloud")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
if appSettings.enableTTS {
|
||||
metaRow(icon: "speaker.wave.2") {
|
||||
Button(action: {
|
||||
viewModel.addBookmarkToSpeechQueue()
|
||||
playerUIState.showPlayer()
|
||||
}) {
|
||||
Text("Read article aloud")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ class BookmarkDetailViewModel {
|
||||
private let getBookmarkArticleUseCase: PGetBookmarkArticleUseCase
|
||||
private let loadSettingsUseCase: PLoadSettingsUseCase
|
||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||
private let addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase
|
||||
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
|
||||
|
||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||
var articleContent: String = ""
|
||||
@ -17,12 +17,14 @@ class BookmarkDetailViewModel {
|
||||
var errorMessage: String?
|
||||
var settings: Settings?
|
||||
|
||||
private var factory: UseCaseFactory?
|
||||
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
|
||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||
self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase()
|
||||
self.factory = factory
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -33,6 +35,9 @@ class BookmarkDetailViewModel {
|
||||
do {
|
||||
settings = try await loadSettingsUseCase.execute()
|
||||
bookmarkDetail = try await getBookmarkUseCase.execute(id: id)
|
||||
if settings?.enableTTS == true {
|
||||
self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Error loading bookmark"
|
||||
}
|
||||
@ -82,7 +87,7 @@ class BookmarkDetailViewModel {
|
||||
|
||||
func addBookmarkToSpeechQueue() {
|
||||
bookmarkDetail.content = articleContent
|
||||
addTextToSpeechQueueUseCase.execute(bookmarkDetail: bookmarkDetail)
|
||||
addTextToSpeechQueueUseCase?.execute(bookmarkDetail: bookmarkDetail)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
85
readeck/UI/Components/RButton.swift
Normal file
85
readeck/UI/Components/RButton.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// RButton.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 21.07.25.
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// This file is part of the readeck project and is licensed under the MIT License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct RButton<Label: View>: View {
|
||||
let action: () -> Void
|
||||
let isLoading: Bool
|
||||
let isDisabled: Bool
|
||||
let icon: String?
|
||||
let label: () -> Label
|
||||
|
||||
init(isLoading: Bool = false, isDisabled: Bool = false, icon: String? = nil, action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) {
|
||||
self.action = action
|
||||
self.isLoading = isLoading
|
||||
self.isDisabled = isDisabled
|
||||
self.icon = icon
|
||||
self.label = label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
if !isLoading && !isDisabled {
|
||||
action()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
}
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
}
|
||||
label()
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxHeight: 60)
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isLoading || isDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Group {
|
||||
RButton(isLoading: false, isDisabled: false, icon: "star.fill", action: {}) {
|
||||
Text("Favorite")
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
.padding()
|
||||
.preferredColorScheme(.light)
|
||||
|
||||
RButton(isLoading: true, isDisabled: false, action: {}) {
|
||||
Text("Loading...")
|
||||
}
|
||||
.padding()
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
RButton(isLoading: false, isDisabled: true, icon: nil, action: {}) {
|
||||
Text("Disabled")
|
||||
}
|
||||
.padding()
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
RButton(isLoading: false, isDisabled: false, icon: nil, action: {}) {
|
||||
Text("No Icon")
|
||||
}
|
||||
.padding()
|
||||
.preferredColorScheme(.light)
|
||||
}
|
||||
}
|
||||
@ -136,6 +136,7 @@ class MockSaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws {}
|
||||
func execute(token: String) async throws {}
|
||||
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {}
|
||||
func execute(enableTTS: Bool) async throws {}
|
||||
}
|
||||
|
||||
class MockGetBookmarkUseCase: PGetBookmarkUseCase {
|
||||
|
||||
@ -2,8 +2,12 @@ import SwiftUI
|
||||
|
||||
struct LabelsView: View {
|
||||
@State var viewModel = LabelsViewModel()
|
||||
@State private var selectedTag: String? = nil
|
||||
@State private var selectedBookmark: Bookmark? = nil
|
||||
@Binding var selectedTag: BookmarkLabel?
|
||||
|
||||
init(viewModel: LabelsViewModel = LabelsViewModel(), selectedTag: Binding<BookmarkLabel?>) {
|
||||
self.viewModel = viewModel
|
||||
self._selectedTag = selectedTag
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
@ -15,15 +19,21 @@ struct LabelsView: View {
|
||||
} 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)
|
||||
if UIDevice.isPhone {
|
||||
NavigationLink {
|
||||
BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name)
|
||||
.navigationTitle("\(label.name) (\(label.count))")
|
||||
} label: {
|
||||
ButtonLabel(label)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
selectedTag = nil
|
||||
DispatchQueue.main.async {
|
||||
selectedTag = label
|
||||
}
|
||||
} label: {
|
||||
ButtonLabel(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,4 +46,14 @@ struct LabelsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ButtonLabel(_ label: BookmarkLabel) -> some View {
|
||||
HStack {
|
||||
Text(label.name)
|
||||
Spacer()
|
||||
Text("\(label.count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,18 @@ import Observation
|
||||
|
||||
@Observable
|
||||
class LabelsViewModel {
|
||||
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
|
||||
private let getLabelsUseCase: PGetLabelsUseCase
|
||||
|
||||
var labels: [BookmarkLabel] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String? = nil
|
||||
var isLoading: Bool
|
||||
var errorMessage: String?
|
||||
|
||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared, labels: [BookmarkLabel] = [], isLoading: Bool = false, errorMessage: String? = nil) {
|
||||
self.labels = labels
|
||||
self.isLoading = isLoading
|
||||
self.errorMessage = errorMessage
|
||||
getLabelsUseCase = factory.makeGetLabelsUseCase()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadLabels() async {
|
||||
|
||||
@ -12,6 +12,7 @@ struct PadSidebarView: View {
|
||||
@State private var selectedBookmark: Bookmark?
|
||||
@State private var selectedTag: BookmarkLabel?
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
||||
|
||||
@ -53,8 +54,11 @@ struct PadSidebarView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg))
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.top, 8)
|
||||
|
||||
if appSettings.enableTTS {
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color(R.color.menu_sidebar_bg))
|
||||
@ -82,7 +86,16 @@ struct PadSidebarView: View {
|
||||
case .pictures:
|
||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
|
||||
case .tags:
|
||||
LabelsView()
|
||||
NavigationStack {
|
||||
LabelsView(selectedTag: $selectedTag)
|
||||
.navigationDestination(item: $selectedTag) { label in
|
||||
BookmarksView(state: .all, type: [], selectedBookmark: $selectedBookmark, tag: label.name)
|
||||
.navigationTitle("\(label.name) (\(label.count))")
|
||||
.onDisappear {
|
||||
selectedTag = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(selectedTab.label)
|
||||
@ -90,7 +103,7 @@ struct PadSidebarView: View {
|
||||
} detail: {
|
||||
if let bookmark = selectedBookmark, selectedTab != .settings {
|
||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||
} else {
|
||||
} else if selectedTab == .settings {
|
||||
Text(selectedTab == .settings ? "" : "Select a bookmark or tag")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
@ -12,7 +12,9 @@ struct PhoneTabView: View {
|
||||
private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings]
|
||||
|
||||
@State private var selectedMoreTab: SidebarTab? = nil
|
||||
@State private var selectedTabIndex: Int = 0
|
||||
@State private var selectedTabIndex: Int = 1
|
||||
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
GlobalPlayerContainerView {
|
||||
@ -48,8 +50,10 @@ struct PhoneTabView: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.bottom, 16)
|
||||
if appSettings.enableTTS {
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis")
|
||||
@ -87,7 +91,7 @@ struct PhoneTabView: View {
|
||||
case .pictures:
|
||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
||||
case .tags:
|
||||
LabelsView()
|
||||
LabelsView(selectedTag: .constant(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
readeck/UI/Models/AppSettings.swift
Normal file
29
readeck/UI/Models/AppSettings.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// AppSettings.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 21.07.25.
|
||||
//
|
||||
|
||||
|
||||
//
|
||||
// AppSettings.swift
|
||||
// readeck
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
class AppSettings: ObservableObject {
|
||||
@Published var settings: Settings?
|
||||
|
||||
var enableTTS: Bool {
|
||||
settings?.enableTTS ?? false
|
||||
}
|
||||
|
||||
init(settings: Settings? = nil) {
|
||||
self.settings = settings
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,13 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsContainerView: View {
|
||||
|
||||
private var appVersion: String {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
||||
return "v\(version) (\(build))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 20) {
|
||||
@ -22,10 +29,45 @@ struct SettingsContainerView: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGroupedBackground))
|
||||
|
||||
AppInfo()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func AppInfo() -> some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Version \(appVersion)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Developer: Ilyas Hallak")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "globe")
|
||||
.foregroundColor(.secondary)
|
||||
Text("From Bremen with 💚")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 4)
|
||||
.multilineTextAlignment(.center)
|
||||
.opacity(0.7)
|
||||
}
|
||||
}
|
||||
|
||||
// Card Modifier für einheitlichen Look
|
||||
|
||||
@ -31,6 +31,21 @@ struct SettingsGeneralView: View {
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("General")
|
||||
.font(.headline)
|
||||
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
||||
.toggleStyle(.switch)
|
||||
.onChange(of: viewModel.enableTTS) {
|
||||
Task {
|
||||
await viewModel.saveGeneralSettings()
|
||||
}
|
||||
}
|
||||
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Sync Settings
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Sync Settings")
|
||||
@ -90,46 +105,6 @@ struct SettingsGeneralView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// App Info
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("About the App")
|
||||
.font(.headline)
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Version \(viewModel.appVersion)")
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Developer: \(viewModel.developerName)")
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "globe")
|
||||
.foregroundColor(.secondary)
|
||||
Link("Website", destination: URL(string: "https://example.com")!)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// Save Button
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.saveGeneralSettings()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text("Save settings")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
// Messages
|
||||
if let successMessage = viewModel.successMessage {
|
||||
HStack {
|
||||
@ -149,6 +124,8 @@ struct SettingsGeneralView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadGeneralSettings()
|
||||
@ -156,21 +133,6 @@ struct SettingsGeneralView: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum Theme: String, CaseIterable {
|
||||
case system = "system"
|
||||
case light = "light"
|
||||
case dark = "dark"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .system: return "System"
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
SettingsGeneralView(viewModel: .init(
|
||||
MockUseCaseFactory()
|
||||
|
||||
@ -14,20 +14,17 @@ class SettingsGeneralViewModel {
|
||||
var syncInterval: Int = 15
|
||||
// MARK: - Reading Settings
|
||||
var enableReaderMode: Bool = false
|
||||
var enableTTS: Bool = false
|
||||
var openExternalLinksInApp: Bool = true
|
||||
var autoMarkAsRead: Bool = false
|
||||
// MARK: - App Info
|
||||
var appVersion: String = "1.0.0"
|
||||
var developerName: String = "Your Name"
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
var errorMessage: String?
|
||||
var successMessage: String?
|
||||
|
||||
// MARK: - Data Management (Placeholder)
|
||||
|
||||
// func clearCache() async {}
|
||||
// func resetSettings() async {}
|
||||
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
@ -37,14 +34,9 @@ class SettingsGeneralViewModel {
|
||||
func loadGeneralSettings() async {
|
||||
do {
|
||||
if let settings = try await loadSettingsUseCase.execute() {
|
||||
selectedTheme = .system // settings.theme ?? .system
|
||||
autoSyncEnabled = false // settings.autoSyncEnabled
|
||||
// syncInterval = settings.syncInterval
|
||||
// enableReaderMode = settings.enableReaderMode
|
||||
// openExternalLinksInApp = settings.openExternalLinksInApp
|
||||
// autoMarkAsRead = settings.autoMarkAsRead
|
||||
appVersion = "1.0.0"
|
||||
developerName = "Ilyas Hallak"
|
||||
enableTTS = settings.enableTTS ?? false
|
||||
selectedTheme = .system
|
||||
autoSyncEnabled = false
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Error loading settings"
|
||||
@ -54,17 +46,7 @@ class SettingsGeneralViewModel {
|
||||
@MainActor
|
||||
func saveGeneralSettings() async {
|
||||
do {
|
||||
|
||||
// TODO: add save general settings here
|
||||
/*try await saveSettingsUseCase.execute(
|
||||
token: "",
|
||||
selectedTheme: selectedTheme,
|
||||
autoSyncEnabled: autoSyncEnabled,
|
||||
syncInterval: syncInterval,
|
||||
enableReaderMode: enableReaderMode,
|
||||
openExternalLinksInApp: openExternalLinksInApp,
|
||||
autoMarkAsRead: autoMarkAsRead
|
||||
)*/
|
||||
try await saveSettingsUseCase.execute(enableTTS: enableTTS)
|
||||
successMessage = "Settings saved"
|
||||
} catch {
|
||||
errorMessage = "Error saving settings"
|
||||
|
||||
@ -18,12 +18,12 @@ struct SettingsServerView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: viewModel.isSetupMode ? "Server-Einstellungen" : "Server-Verbindung", icon: "server.rack")
|
||||
SectionHeader(title: viewModel.isSetupMode ? "Server Settings" : "Server Connection", icon: "server.rack")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Text(viewModel.isSetupMode ?
|
||||
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." :
|
||||
"Ihre aktuelle Server-Verbindung und Anmeldedaten.")
|
||||
"Enter your Readeck server details to get started." :
|
||||
"Your current server connection and login credentials.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@ -32,7 +32,7 @@ struct SettingsServerView: View {
|
||||
// Form
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Server-Endpunkt")
|
||||
Text("Server Endpoint")
|
||||
.font(.headline)
|
||||
TextField("https://readeck.example.com", text: $viewModel.endpoint)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
@ -79,7 +79,7 @@ struct SettingsServerView: View {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Erfolgreich angemeldet")
|
||||
Text("Successfully logged in")
|
||||
.foregroundColor(.green)
|
||||
.font(.caption)
|
||||
}
|
||||
@ -119,7 +119,7 @@ struct SettingsServerView: View {
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(viewModel.isLoading ? "Speichern..." : (viewModel.isLoggedIn ? "Erneut anmelden & speichern" : "Anmelden & speichern"))
|
||||
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@ -136,7 +136,7 @@ struct SettingsServerView: View {
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
Text("Abmelden")
|
||||
Text("Logout")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@ -147,15 +147,15 @@ struct SettingsServerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Abmelden", isPresented: $showingLogoutAlert) {
|
||||
Button("Abbrechen", role: .cancel) { }
|
||||
Button("Abmelden", role: .destructive) {
|
||||
.alert("Logout", isPresented: $showingLogoutAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Logout", role: .destructive) {
|
||||
Task {
|
||||
await viewModel.logout()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen.")
|
||||
Text("Are you sure you want to log out? This will delete all your login credentials and return you to setup.")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadServerSettings()
|
||||
|
||||
@ -4,6 +4,7 @@ struct GlobalPlayerContainerView<Content: View>: View {
|
||||
let content: Content
|
||||
@StateObject private var viewModel = SpeechPlayerViewModel()
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
@ -14,7 +15,7 @@ struct GlobalPlayerContainerView<Content: View>: View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
if viewModel.hasItems && playerUIState.isPlayerVisible {
|
||||
if appSettings.enableTTS && viewModel.hasItems && playerUIState.isPlayerVisible {
|
||||
VStack(spacing: 0) {
|
||||
SpeechPlayerView(onClose: { playerUIState.hidePlayer() })
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
@ -38,6 +38,11 @@ struct SpeechPlayerView: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
.onAppear() {
|
||||
Task {
|
||||
await viewModel.setup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,9 @@ import Foundation
|
||||
import Combine
|
||||
|
||||
class SpeechPlayerViewModel: ObservableObject {
|
||||
private let ttsManager: TTSManager
|
||||
private let speechQueue: SpeechQueue
|
||||
private var ttsManager: TTSManager? = nil
|
||||
private var speechQueue: SpeechQueue? = nil
|
||||
private let loadSettingsUseCase: PLoadSettingsUseCase
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@Published var isSpeaking: Bool = false
|
||||
@ -18,79 +19,86 @@ class SpeechPlayerViewModel: ObservableObject {
|
||||
@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()
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
}
|
||||
|
||||
func setup() async {
|
||||
let settings = try? await loadSettingsUseCase.execute()
|
||||
if settings?.enableTTS == true {
|
||||
self.ttsManager = .shared
|
||||
self.speechQueue = .shared
|
||||
setupBindings()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupBindings() {
|
||||
// TTSManager bindings
|
||||
ttsManager.$isSpeaking
|
||||
ttsManager?.$isSpeaking
|
||||
.assign(to: \.isSpeaking, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
ttsManager.$currentUtterance
|
||||
ttsManager?.$currentUtterance
|
||||
.assign(to: \.currentText, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
// SpeechQueue bindings
|
||||
speechQueue.$queueItems
|
||||
speechQueue?.$queueItems
|
||||
.assign(to: \.queueItems, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
speechQueue.$queueItems
|
||||
speechQueue?.$queueItems
|
||||
.map { $0.count }
|
||||
.assign(to: \.queueCount, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
speechQueue.$hasItems
|
||||
speechQueue?.$hasItems
|
||||
.assign(to: \.hasItems, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
// TTS Progress bindings
|
||||
ttsManager.$progress
|
||||
ttsManager?.$progress
|
||||
.assign(to: \.progress, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
ttsManager.$currentUtteranceIndex
|
||||
ttsManager?.$currentUtteranceIndex
|
||||
.assign(to: \.currentUtteranceIndex, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
ttsManager.$totalUtterances
|
||||
ttsManager?.$totalUtterances
|
||||
.assign(to: \.totalUtterances, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
ttsManager.$articleProgress
|
||||
ttsManager?.$articleProgress
|
||||
.assign(to: \.articleProgress, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
ttsManager.$volume
|
||||
ttsManager?.$volume
|
||||
.assign(to: \.volume, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
ttsManager.$rate
|
||||
ttsManager?.$rate
|
||||
.assign(to: \.rate, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func setVolume(_ newVolume: Float) {
|
||||
ttsManager.setVolume(newVolume)
|
||||
ttsManager?.setVolume(newVolume)
|
||||
}
|
||||
|
||||
func setRate(_ newRate: Float) {
|
||||
ttsManager.setRate(newRate)
|
||||
ttsManager?.setRate(newRate)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
ttsManager.pause()
|
||||
ttsManager?.pause()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
ttsManager.resume()
|
||||
ttsManager?.resume()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
ttsManager.stop()
|
||||
ttsManager?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,34 +10,42 @@ import netfox
|
||||
|
||||
@main
|
||||
struct readeckApp: App {
|
||||
let persistenceController = PersistenceController.shared
|
||||
@State private var hasFinishedSetup = true
|
||||
|
||||
@StateObject private var appSettings = AppSettings()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if hasFinishedSetup {
|
||||
MainTabView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
} else {
|
||||
SettingsServerView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.environmentObject(appSettings)
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
NFX.sharedInstance().start()
|
||||
#endif
|
||||
loadSetupStatus()
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in
|
||||
loadSetupStatus()
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSetupStatus() {
|
||||
|
||||
private func loadSetupStatus() async {
|
||||
let settingsRepository = SettingsRepository()
|
||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
||||
let settings = try? await settingsRepository.loadSettings()
|
||||
await MainActor.run {
|
||||
appSettings.settings = settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
||||
<attribute name="fontFamily" optional="YES" attributeType="String"/>
|
||||
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user