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" : {
},
"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" : {

View File

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

View File

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

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

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

View File

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

View File

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

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(token: String) async throws {}
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {}
func execute(enableTTS: Bool) async throws {}
}
class MockGetBookmarkUseCase: PGetBookmarkUseCase {

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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