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:
Ilyas Hallak 2025-07-21 23:37:37 +02:00
parent 387a026e7d
commit 8d4b08da11
26 changed files with 431 additions and 285 deletions

View File

@ -42,19 +42,13 @@
"12 min • Today • example.com" : { "12 min • Today • example.com" : {
}, },
"Abbrechen" : { "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." : {
},
"Abmelden" : {
},
"About the App" : {
}, },
"Add a new link to your collection" : { "Add a new link to your collection" : {
}, },
"Aktuelle Labels" : { "Add new label" : {
}, },
"all" : { "all" : {
@ -67,15 +61,15 @@
} }
} }
} }
},
"Anmelden & speichern" : {
}, },
"Archive" : { "Archive" : {
}, },
"Archive bookmark" : { "Archive bookmark" : {
},
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
}, },
"Automatic sync" : { "Automatic sync" : {
@ -94,6 +88,9 @@
}, },
"Close" : { "Close" : {
},
"Current labels" : {
}, },
"Data Management" : { "Data Management" : {
@ -101,7 +98,7 @@
"Delete" : { "Delete" : {
}, },
"Developer: %@" : { "Developer: Ilyas Hallak" : {
}, },
"Done" : { "Done" : {
@ -110,10 +107,10 @@
"e.g. work, important, later" : { "e.g. work, important, later" : {
}, },
"Erfolgreich angemeldet" : { "Enter label..." : {
}, },
"Erneut anmelden & speichern" : { "Enter your Readeck server details to get started." : {
}, },
"Error" : { "Error" : {
@ -124,12 +121,6 @@
}, },
"Favorite" : { "Favorite" : {
},
"Fehler" : {
},
"Fertig" : {
}, },
"Finished reading?" : { "Finished reading?" : {
@ -146,7 +137,10 @@
"Font size" : { "Font size" : {
}, },
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : { "From Bremen with 💚" : {
},
"General" : {
}, },
"https://example.com" : { "https://example.com" : {
@ -154,48 +148,39 @@
}, },
"https://readeck.example.com" : { "https://readeck.example.com" : {
},
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
}, },
"Keine Bookmarks gefunden." : { "Keine Bookmarks gefunden." : {
}, },
"Keine Ergebnisse" : { "Keine Ergebnisse" : {
},
"Keine Labels vorhanden" : {
}, },
"Key" : { "Key" : {
"extractionState" : "manual" "extractionState" : "manual"
},
"Label eingeben..." : {
}, },
"Labels" : { "Labels" : {
},
"Labels verwalten" : {
}, },
"Loading %@..." : { "Loading %@..." : {
}, },
"Loading article..." : { "Loading article..." : {
},
"Login & Save" : {
},
"Logout" : {
},
"Manage Labels" : {
}, },
"Mark as favorite" : { "Mark as favorite" : {
},
"Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen." : {
}, },
"More" : { "More" : {
},
"Neues Label hinzufügen" : {
}, },
"New Bookmark" : { "New Bookmark" : {
@ -208,6 +193,9 @@
}, },
"No bookmarks found in %@." : { "No bookmarks found in %@." : {
},
"No labels available" : {
}, },
"OK" : { "OK" : {
@ -229,6 +217,12 @@
}, },
"Progress: %lld%%" : { "Progress: %lld%%" : {
},
"Re-login & Save" : {
},
"Read Aloud Feature" : {
}, },
"Read article aloud" : { "Read article aloud" : {
@ -272,9 +266,6 @@
}, },
"Save bookmark" : { "Save bookmark" : {
},
"Save settings" : {
}, },
"Saving..." : { "Saving..." : {
@ -282,7 +273,7 @@
"Select a bookmark or tag" : { "Select a bookmark or tag" : {
}, },
"Server-Endpunkt" : { "Server Endpoint" : {
}, },
"Settings" : { "Settings" : {
@ -291,7 +282,7 @@
"Speed" : { "Speed" : {
}, },
"Speichern..." : { "Successfully logged in" : {
}, },
"Suchbegriff eingeben..." : { "Suchbegriff eingeben..." : {
@ -330,7 +321,7 @@
"Version %@" : { "Version %@" : {
}, },
"Website" : { "Your current server connection and login credentials." : {
}, },
"Your Password" : { "Your Password" : {

View File

@ -34,7 +34,7 @@ class ShareViewController: UIViewController {
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
// Add cancel button // 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 cancelButton.tintColor = UIColor.white
navigationItem.leftBarButtonItem = cancelButton navigationItem.leftBarButtonItem = cancelButton
@ -54,14 +54,12 @@ class ShareViewController: UIViewController {
// Add custom cancel button // Add custom cancel button
let customCancelButton = UIButton(type: .system) let customCancelButton = UIButton(type: .system)
customCancelButton.translatesAutoresizingMaskIntoConstraints = false customCancelButton.translatesAutoresizingMaskIntoConstraints = false
customCancelButton.setTitle("Abbrechen", for: .normal) customCancelButton.setTitle("Cancel", for: .normal)
customCancelButton.setTitleColor(UIColor.white, for: .normal) customCancelButton.setTitleColor(UIColor.white, for: .normal)
customCancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium) customCancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
customCancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) customCancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
view.addSubview(customCancelButton) view.addSubview(customCancelButton)
// URL Container View // URL Container View
let urlContainerView = UIView() let urlContainerView = UIView()
urlContainerView.translatesAutoresizingMaskIntoConstraints = false urlContainerView.translatesAutoresizingMaskIntoConstraints = false
@ -79,7 +77,7 @@ class ShareViewController: UIViewController {
urlLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) urlLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
urlLabel?.textColor = UIColor.label urlLabel?.textColor = UIColor.label
urlLabel?.numberOfLines = 0 urlLabel?.numberOfLines = 0
urlLabel?.text = "URL wird geladen..." urlLabel?.text = "Loading URL..."
urlLabel?.textAlignment = .left urlLabel?.textAlignment = .left
urlContainerView.addSubview(urlLabel!) urlContainerView.addSubview(urlLabel!)
@ -97,7 +95,7 @@ class ShareViewController: UIViewController {
// Title TextField // Title TextField
titleTextField = UITextField() titleTextField = UITextField()
titleTextField?.translatesAutoresizingMaskIntoConstraints = false titleTextField?.translatesAutoresizingMaskIntoConstraints = false
titleTextField?.placeholder = "Optionales Titel eingeben..." titleTextField?.placeholder = "Enter an optional title..."
titleTextField?.borderStyle = .none titleTextField?.borderStyle = .none
titleTextField?.font = UIFont.systemFont(ofSize: 16) titleTextField?.font = UIFont.systemFont(ofSize: 16)
titleTextField?.backgroundColor = UIColor.clear titleTextField?.backgroundColor = UIColor.clear
@ -114,13 +112,22 @@ class ShareViewController: UIViewController {
statusLabel?.layer.masksToBounds = true statusLabel?.layer.masksToBounds = true
view.addSubview(statusLabel!) view.addSubview(statusLabel!)
let isDarkMode = traitCollection.userInterfaceStyle == .dark
// Save Button // Save Button
saveButton = UIButton(type: .system) saveButton = UIButton(type: .system)
saveButton?.translatesAutoresizingMaskIntoConstraints = false 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?.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.cornerRadius = 16
saveButton?.layer.shadowColor = UIColor.black.cgColor saveButton?.layer.shadowColor = UIColor.black.cgColor
saveButton?.layer.shadowOffset = CGSize(width: 0, height: 4) saveButton?.layer.shadowOffset = CGSize(width: 0, height: 4)
@ -129,7 +136,6 @@ class ShareViewController: UIViewController {
saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside) saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
view.addSubview(saveButton!) view.addSubview(saveButton!)
// Activity Indicator // Activity Indicator
activityIndicator = UIActivityIndicatorView(style: .medium) activityIndicator = UIActivityIndicatorView(style: .medium)
activityIndicator?.translatesAutoresizingMaskIntoConstraints = false activityIndicator?.translatesAutoresizingMaskIntoConstraints = false
@ -262,29 +268,29 @@ class ShareViewController: UIViewController {
// MARK: - API Call // MARK: - API Call
private func addBookmarkViaAPI(title: String) async { private func addBookmarkViaAPI(title: String) async {
guard let url = extractedURL, !url.isEmpty else { guard let url = extractedURL, !url.isEmpty else {
showStatus("Keine URL gefunden.", error: true) showStatus("No URL found.", error: true)
return return
} }
// Token und Endpoint aus KeychainHelper // Token und Endpoint aus KeychainHelper
guard let token = KeychainHelper.shared.loadToken() else { 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 return
} }
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else { guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
showStatus("Kein Server-Endpunkt gefunden.", error: true) showStatus("No server endpoint found.", error: true)
return return
} }
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: []) let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
guard let requestData = try? JSONEncoder().encode(requestDto) else { guard let requestData = try? JSONEncoder().encode(requestDto) else {
showStatus("Fehler beim Kodieren der Anfrage.", error: true) showStatus("Failed to encode request.", error: true)
return return
} }
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else { guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
showStatus("Ungültiger Server-Endpunkt.", error: true) showStatus("Invalid server endpoint.", error: true)
return return
} }
@ -298,24 +304,24 @@ class ShareViewController: UIViewController {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
showStatus("Ungültige Server-Antwort.", error: true) showStatus("Invalid server response.", error: true)
return return
} }
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unbekannter Fehler" let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
showStatus("Serverfehler: \(httpResponse.statusCode)\n\(msg)", error: true) showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", error: true)
return return
} }
// Optional: Response parsen // Optional: Response parsen
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) { if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
showStatus("Gespeichert: \(resp.message)", error: false) showStatus("Saved: \(resp.message)", error: false)
} else { } else {
showStatus("Lesezeichen gespeichert!", error: false) showStatus("Bookmark saved!", error: false)
} }
} catch { } 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 { private struct CreateBookmarkRequestDto: Codable {
let labels: [String]? let labels: [String]?
let title: String? let title: String?

View File

@ -609,7 +609,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@ -653,7 +653,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;

View File

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

View File

@ -10,6 +10,7 @@ struct Settings {
var fontFamily: FontFamily? = nil var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var isLoggedIn: Bool { var isLoggedIn: Bool {
token != nil && !token!.isEmpty token != nil && !token!.isEmpty
@ -69,6 +70,9 @@ class SettingsRepository: PSettingsRepository {
if let fontSize = settings.fontSize { if let fontSize = settings.fontSize {
existingSettings.fontSize = fontSize.rawValue existingSettings.fontSize = fontSize.rawValue
} }
if let enableTTS = settings.enableTTS {
existingSettings.enableTTS = enableTTS
}
try context.save() try context.save()
} }
@ -99,7 +103,8 @@ class SettingsRepository: PSettingsRepository {
password: settingEntity.password ?? "", password: settingEntity.password ?? "",
token: settingEntity.token, token: settingEntity.token,
fontFamily: FontFamily(rawValue: settingEntity.fontFamily ?? FontFamily.system.rawValue), 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) continuation.resume(returning: settings)
} else { } else {

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

View File

@ -5,6 +5,7 @@ protocol PSaveSettingsUseCase {
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws
func execute(token: String) async throws func execute(token: String) async throws
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
func execute(enableTTS: Bool) async throws
} }
class SaveSettingsUseCase: PSaveSettingsUseCase { class SaveSettingsUseCase: PSaveSettingsUseCase {
@ -51,4 +52,10 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
) )
) )
} }
func execute(enableTTS: Bool) async throws {
try await settingsRepository.saveSettings(
.init(enableTTS: enableTTS)
)
}
} }

View File

@ -8,6 +8,7 @@ struct BookmarkDetailView: View {
@State private var showingFontSettings = false @State private var showingFontSettings = false
@State private var showingLabelsSheet = false @State private var showingLabelsSheet = false
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
private let headerHeight: CGFloat = 320 private let headerHeight: CGFloat = 320
@ -242,14 +243,16 @@ struct BookmarkDetailView: View {
} }
} }
metaRow(icon: "speaker.wave.2") { if appSettings.enableTTS {
Button(action: { metaRow(icon: "speaker.wave.2") {
viewModel.addBookmarkToSpeechQueue() Button(action: {
playerUIState.showPlayer() viewModel.addBookmarkToSpeechQueue()
}) { playerUIState.showPlayer()
Text("Read article aloud") }) {
.font(.subheadline) Text("Read article aloud")
.foregroundColor(.secondary) .font(.subheadline)
.foregroundColor(.secondary)
}
} }
} }
} }

View File

@ -6,7 +6,7 @@ class BookmarkDetailViewModel {
private let getBookmarkArticleUseCase: PGetBookmarkArticleUseCase private let getBookmarkArticleUseCase: PGetBookmarkArticleUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = "" var articleContent: String = ""
@ -17,12 +17,14 @@ class BookmarkDetailViewModel {
var errorMessage: String? var errorMessage: String?
var settings: Settings? var settings: Settings?
private var factory: UseCaseFactory?
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase() self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase() self.factory = factory
} }
@MainActor @MainActor
@ -33,6 +35,9 @@ class BookmarkDetailViewModel {
do { do {
settings = try await loadSettingsUseCase.execute() settings = try await loadSettingsUseCase.execute()
bookmarkDetail = try await getBookmarkUseCase.execute(id: id) bookmarkDetail = try await getBookmarkUseCase.execute(id: id)
if settings?.enableTTS == true {
self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase()
}
} catch { } catch {
errorMessage = "Error loading bookmark" errorMessage = "Error loading bookmark"
} }
@ -82,7 +87,7 @@ class BookmarkDetailViewModel {
func addBookmarkToSpeechQueue() { func addBookmarkToSpeechQueue() {
bookmarkDetail.content = articleContent bookmarkDetail.content = articleContent
addTextToSpeechQueueUseCase.execute(bookmarkDetail: bookmarkDetail) addTextToSpeechQueueUseCase?.execute(bookmarkDetail: bookmarkDetail)
} }
@MainActor @MainActor

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

View File

@ -136,6 +136,7 @@ class MockSaveSettingsUseCase: PSaveSettingsUseCase {
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws {} func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws {}
func execute(token: String) async throws {} func execute(token: String) async throws {}
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {} func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {}
func execute(enableTTS: Bool) async throws {}
} }
class MockGetBookmarkUseCase: PGetBookmarkUseCase { class MockGetBookmarkUseCase: PGetBookmarkUseCase {

View File

@ -2,8 +2,12 @@ import SwiftUI
struct LabelsView: View { struct LabelsView: View {
@State var viewModel = LabelsViewModel() @State var viewModel = LabelsViewModel()
@State private var selectedTag: String? = nil @Binding var selectedTag: BookmarkLabel?
@State private var selectedBookmark: Bookmark? = nil
init(viewModel: LabelsViewModel = LabelsViewModel(), selectedTag: Binding<BookmarkLabel?>) {
self.viewModel = viewModel
self._selectedTag = selectedTag
}
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@ -15,15 +19,21 @@ struct LabelsView: View {
} else { } else {
List { List {
ForEach(viewModel.labels, id: \.href) { label in ForEach(viewModel.labels, id: \.href) { label in
NavigationLink { if UIDevice.isPhone {
BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name) NavigationLink {
.navigationTitle("\(label.name) (\(label.count))") BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name)
} label: { .navigationTitle("\(label.name) (\(label.count))")
HStack { } label: {
Text(label.name) ButtonLabel(label)
Spacer() }
Text("\(label.count)") } else {
.foregroundColor(.secondary) 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)
}
}
}

View File

@ -3,11 +3,18 @@ import Observation
@Observable @Observable
class LabelsViewModel { class LabelsViewModel {
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase() private let getLabelsUseCase: PGetLabelsUseCase
var labels: [BookmarkLabel] = [] var labels: [BookmarkLabel] = []
var isLoading = false var isLoading: Bool
var errorMessage: String? = nil 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 @MainActor
func loadLabels() async { func loadLabels() async {

View File

@ -12,6 +12,7 @@ struct PadSidebarView: View {
@State private var selectedBookmark: Bookmark? @State private var selectedBookmark: Bookmark?
@State private var selectedTag: BookmarkLabel? @State private var selectedTag: BookmarkLabel?
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags] private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
@ -53,8 +54,11 @@ struct PadSidebarView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg)) .listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg))
PlayerQueueResumeButton()
.padding(.top, 8) if appSettings.enableTTS {
PlayerQueueResumeButton()
.padding(.top, 8)
}
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(Color(R.color.menu_sidebar_bg)) .background(Color(R.color.menu_sidebar_bg))
@ -82,7 +86,16 @@ struct PadSidebarView: View {
case .pictures: case .pictures:
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark) BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
case .tags: 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) .navigationTitle(selectedTab.label)
@ -90,7 +103,7 @@ struct PadSidebarView: View {
} detail: { } detail: {
if let bookmark = selectedBookmark, selectedTab != .settings { if let bookmark = selectedBookmark, selectedTab != .settings {
BookmarkDetailView(bookmarkId: bookmark.id) BookmarkDetailView(bookmarkId: bookmark.id)
} else { } else if selectedTab == .settings {
Text(selectedTab == .settings ? "" : "Select a bookmark or tag") Text(selectedTab == .settings ? "" : "Select a bookmark or tag")
.foregroundColor(.gray) .foregroundColor(.gray)
} }

View File

@ -12,7 +12,9 @@ struct PhoneTabView: View {
private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings] private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings]
@State private var selectedMoreTab: SidebarTab? = nil @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 { var body: some View {
GlobalPlayerContainerView { GlobalPlayerContainerView {
@ -48,8 +50,10 @@ struct PhoneTabView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color(R.color.bookmark_list_bg)) .background(Color(R.color.bookmark_list_bg))
PlayerQueueResumeButton() if appSettings.enableTTS {
.padding(.bottom, 16) PlayerQueueResumeButton()
.padding(.top, 16)
}
} }
.tabItem { .tabItem {
Label("More", systemImage: "ellipsis") Label("More", systemImage: "ellipsis")
@ -87,7 +91,7 @@ struct PhoneTabView: View {
case .pictures: case .pictures:
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil)) BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
case .tags: case .tags:
LabelsView() LabelsView(selectedTag: .constant(nil))
} }
} }
} }

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

View File

@ -8,6 +8,13 @@
import SwiftUI import SwiftUI
struct SettingsContainerView: View { 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 { var body: some View {
ScrollView { ScrollView {
LazyVStack(spacing: 20) { LazyVStack(spacing: 20) {
@ -22,10 +29,45 @@ struct SettingsContainerView: View {
} }
.padding() .padding()
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
AppInfo()
} }
.background(Color(.systemGroupedBackground))
.navigationTitle("Settings") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large) .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 // Card Modifier für einheitlichen Look

View File

@ -31,6 +31,21 @@ struct SettingsGeneralView: View {
.pickerStyle(.segmented) .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 // Sync Settings
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Sync Settings") 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 // Messages
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
HStack { HStack {
@ -149,6 +124,8 @@ struct SettingsGeneralView: View {
.font(.caption) .font(.caption)
} }
} }
#endif
} }
.task { .task {
await viewModel.loadGeneralSettings() 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 { #Preview {
SettingsGeneralView(viewModel: .init( SettingsGeneralView(viewModel: .init(
MockUseCaseFactory() MockUseCaseFactory()

View File

@ -14,20 +14,17 @@ class SettingsGeneralViewModel {
var syncInterval: Int = 15 var syncInterval: Int = 15
// MARK: - Reading Settings // MARK: - Reading Settings
var enableReaderMode: Bool = false var enableReaderMode: Bool = false
var enableTTS: Bool = false
var openExternalLinksInApp: Bool = true var openExternalLinksInApp: Bool = true
var autoMarkAsRead: Bool = false var autoMarkAsRead: Bool = false
// MARK: - App Info
var appVersion: String = "1.0.0"
var developerName: String = "Your Name"
// MARK: - Messages // MARK: - Messages
var errorMessage: String? var errorMessage: String?
var successMessage: String? var successMessage: String?
// MARK: - Data Management (Placeholder) // MARK: - Data Management (Placeholder)
// func clearCache() async {}
// func resetSettings() async {}
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase() self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
@ -37,14 +34,9 @@ class SettingsGeneralViewModel {
func loadGeneralSettings() async { func loadGeneralSettings() async {
do { do {
if let settings = try await loadSettingsUseCase.execute() { if let settings = try await loadSettingsUseCase.execute() {
selectedTheme = .system // settings.theme ?? .system enableTTS = settings.enableTTS ?? false
autoSyncEnabled = false // settings.autoSyncEnabled selectedTheme = .system
// syncInterval = settings.syncInterval autoSyncEnabled = false
// enableReaderMode = settings.enableReaderMode
// openExternalLinksInApp = settings.openExternalLinksInApp
// autoMarkAsRead = settings.autoMarkAsRead
appVersion = "1.0.0"
developerName = "Ilyas Hallak"
} }
} catch { } catch {
errorMessage = "Error loading settings" errorMessage = "Error loading settings"
@ -54,17 +46,7 @@ class SettingsGeneralViewModel {
@MainActor @MainActor
func saveGeneralSettings() async { func saveGeneralSettings() async {
do { do {
try await saveSettingsUseCase.execute(enableTTS: enableTTS)
// TODO: add save general settings here
/*try await saveSettingsUseCase.execute(
token: "",
selectedTheme: selectedTheme,
autoSyncEnabled: autoSyncEnabled,
syncInterval: syncInterval,
enableReaderMode: enableReaderMode,
openExternalLinksInApp: openExternalLinksInApp,
autoMarkAsRead: autoMarkAsRead
)*/
successMessage = "Settings saved" successMessage = "Settings saved"
} catch { } catch {
errorMessage = "Error saving settings" errorMessage = "Error saving settings"

View File

@ -18,12 +18,12 @@ struct SettingsServerView: View {
var body: some View { var body: some View {
VStack(spacing: 20) { 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) .padding(.bottom, 4)
Text(viewModel.isSetupMode ? Text(viewModel.isSetupMode ?
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : "Enter your Readeck server details to get started." :
"Ihre aktuelle Server-Verbindung und Anmeldedaten.") "Your current server connection and login credentials.")
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -32,7 +32,7 @@ struct SettingsServerView: View {
// Form // Form
VStack(spacing: 16) { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Server-Endpunkt") Text("Server Endpoint")
.font(.headline) .font(.headline)
TextField("https://readeck.example.com", text: $viewModel.endpoint) TextField("https://readeck.example.com", text: $viewModel.endpoint)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
@ -79,7 +79,7 @@ struct SettingsServerView: View {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
Text("Erfolgreich angemeldet") Text("Successfully logged in")
.foregroundColor(.green) .foregroundColor(.green)
.font(.caption) .font(.caption)
} }
@ -119,7 +119,7 @@ struct SettingsServerView: View {
.scaleEffect(0.8) .scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .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) .fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -136,7 +136,7 @@ struct SettingsServerView: View {
}) { }) {
HStack { HStack {
Image(systemName: "rectangle.portrait.and.arrow.right") Image(systemName: "rectangle.portrait.and.arrow.right")
Text("Abmelden") Text("Logout")
.fontWeight(.semibold) .fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -147,15 +147,15 @@ struct SettingsServerView: View {
} }
} }
} }
.alert("Abmelden", isPresented: $showingLogoutAlert) { .alert("Logout", isPresented: $showingLogoutAlert) {
Button("Abbrechen", role: .cancel) { } Button("Cancel", role: .cancel) { }
Button("Abmelden", role: .destructive) { Button("Logout", role: .destructive) {
Task { Task {
await viewModel.logout() await viewModel.logout()
} }
} }
} message: { } 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 { .task {
await viewModel.loadServerSettings() await viewModel.loadServerSettings()

View File

@ -4,6 +4,7 @@ struct GlobalPlayerContainerView<Content: View>: View {
let content: Content let content: Content
@StateObject private var viewModel = SpeechPlayerViewModel() @StateObject private var viewModel = SpeechPlayerViewModel()
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
init(@ViewBuilder content: () -> Content) { init(@ViewBuilder content: () -> Content) {
self.content = content() self.content = content()
@ -14,7 +15,7 @@ struct GlobalPlayerContainerView<Content: View>: View {
content content
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
if viewModel.hasItems && playerUIState.isPlayerVisible { if appSettings.enableTTS && viewModel.hasItems && playerUIState.isPlayerVisible {
VStack(spacing: 0) { VStack(spacing: 0) {
SpeechPlayerView(onClose: { playerUIState.hidePlayer() }) SpeechPlayerView(onClose: { playerUIState.hidePlayer() })
.padding(.horizontal, 16) .padding(.horizontal, 16)

View File

@ -38,6 +38,11 @@ struct SpeechPlayerView: View {
} }
} }
) )
.onAppear() {
Task {
await viewModel.setup()
}
}
} }
} }

View File

@ -2,8 +2,9 @@ import Foundation
import Combine import Combine
class SpeechPlayerViewModel: ObservableObject { class SpeechPlayerViewModel: ObservableObject {
private let ttsManager: TTSManager private var ttsManager: TTSManager? = nil
private let speechQueue: SpeechQueue private var speechQueue: SpeechQueue? = nil
private let loadSettingsUseCase: PLoadSettingsUseCase
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@Published var isSpeaking: Bool = false @Published var isSpeaking: Bool = false
@ -18,79 +19,86 @@ class SpeechPlayerViewModel: ObservableObject {
@Published var volume: Float = 1.0 @Published var volume: Float = 1.0
@Published var rate: Float = 0.5 @Published var rate: Float = 0.5
init(ttsManager: TTSManager = .shared, speechQueue: SpeechQueue = .shared) { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.ttsManager = ttsManager loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.speechQueue = speechQueue }
setupBindings()
func setup() async {
let settings = try? await loadSettingsUseCase.execute()
if settings?.enableTTS == true {
self.ttsManager = .shared
self.speechQueue = .shared
setupBindings()
}
} }
private func setupBindings() { private func setupBindings() {
// TTSManager bindings // TTSManager bindings
ttsManager.$isSpeaking ttsManager?.$isSpeaking
.assign(to: \.isSpeaking, on: self) .assign(to: \.isSpeaking, on: self)
.store(in: &cancellables) .store(in: &cancellables)
ttsManager.$currentUtterance ttsManager?.$currentUtterance
.assign(to: \.currentText, on: self) .assign(to: \.currentText, on: self)
.store(in: &cancellables) .store(in: &cancellables)
// SpeechQueue bindings // SpeechQueue bindings
speechQueue.$queueItems speechQueue?.$queueItems
.assign(to: \.queueItems, on: self) .assign(to: \.queueItems, on: self)
.store(in: &cancellables) .store(in: &cancellables)
speechQueue.$queueItems speechQueue?.$queueItems
.map { $0.count } .map { $0.count }
.assign(to: \.queueCount, on: self) .assign(to: \.queueCount, on: self)
.store(in: &cancellables) .store(in: &cancellables)
speechQueue.$hasItems speechQueue?.$hasItems
.assign(to: \.hasItems, on: self) .assign(to: \.hasItems, on: self)
.store(in: &cancellables) .store(in: &cancellables)
// TTS Progress bindings // TTS Progress bindings
ttsManager.$progress ttsManager?.$progress
.assign(to: \.progress, on: self) .assign(to: \.progress, on: self)
.store(in: &cancellables) .store(in: &cancellables)
ttsManager.$currentUtteranceIndex ttsManager?.$currentUtteranceIndex
.assign(to: \.currentUtteranceIndex, on: self) .assign(to: \.currentUtteranceIndex, on: self)
.store(in: &cancellables) .store(in: &cancellables)
ttsManager.$totalUtterances ttsManager?.$totalUtterances
.assign(to: \.totalUtterances, on: self) .assign(to: \.totalUtterances, on: self)
.store(in: &cancellables) .store(in: &cancellables)
ttsManager.$articleProgress ttsManager?.$articleProgress
.assign(to: \.articleProgress, on: self) .assign(to: \.articleProgress, on: self)
.store(in: &cancellables) .store(in: &cancellables)
ttsManager.$volume ttsManager?.$volume
.assign(to: \.volume, on: self) .assign(to: \.volume, on: self)
.store(in: &cancellables) .store(in: &cancellables)
ttsManager.$rate ttsManager?.$rate
.assign(to: \.rate, on: self) .assign(to: \.rate, on: self)
.store(in: &cancellables) .store(in: &cancellables)
} }
func setVolume(_ newVolume: Float) { func setVolume(_ newVolume: Float) {
ttsManager.setVolume(newVolume) ttsManager?.setVolume(newVolume)
} }
func setRate(_ newRate: Float) { func setRate(_ newRate: Float) {
ttsManager.setRate(newRate) ttsManager?.setRate(newRate)
} }
func pause() { func pause() {
ttsManager.pause() ttsManager?.pause()
} }
func resume() { func resume() {
ttsManager.resume() ttsManager?.resume()
} }
func stop() { func stop() {
ttsManager.stop() ttsManager?.stop()
} }
} }

View File

@ -10,34 +10,42 @@ import netfox
@main @main
struct readeckApp: App { struct readeckApp: App {
let persistenceController = PersistenceController.shared
@State private var hasFinishedSetup = true @State private var hasFinishedSetup = true
@StateObject private var appSettings = AppSettings()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
Group { Group {
if hasFinishedSetup { if hasFinishedSetup {
MainTabView() MainTabView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
} else { } else {
SettingsServerView() SettingsServerView()
.padding() .padding()
} }
} }
.environmentObject(appSettings)
.onAppear { .onAppear {
#if DEBUG #if DEBUG
NFX.sharedInstance().start() NFX.sharedInstance().start()
#endif #endif
loadSetupStatus() Task {
await loadSetupStatus()
}
} }
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in
loadSetupStatus() Task {
await loadSetupStatus()
}
} }
} }
} }
private func loadSetupStatus() { private func loadSetupStatus() async {
let settingsRepository = SettingsRepository() let settingsRepository = SettingsRepository()
hasFinishedSetup = settingsRepository.hasFinishedSetup hasFinishedSetup = settingsRepository.hasFinishedSetup
let settings = try? await settingsRepository.loadSettings()
await MainActor.run {
appSettings.settings = settings
}
} }
} }

View File

@ -1,9 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?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=""> <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"> <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="endpoint" optional="YES" attributeType="String"/>
<attribute name="fontFamily" optional="YES" attributeType="String"/> <attribute name="fontFamily" optional="YES" attributeType="String"/>
<attribute name="fontSize" optional="YES" attributeType="String"/> <attribute name="fontSize" optional="YES" attributeType="String"/>