diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 040aee1..9fa0c95 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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" : { diff --git a/URLShare/ShareViewController.swift b/URLShare/ShareViewController.swift index 15156c2..8e5d301 100644 --- a/URLShare/ShareViewController.swift +++ b/URLShare/ShareViewController.swift @@ -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? diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 28b88fa..3a19efc 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -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; diff --git a/readeck/Data/PersistenceController.swift b/readeck/Data/PersistenceController.swift deleted file mode 100644 index 01c23eb..0000000 --- a/readeck/Data/PersistenceController.swift +++ /dev/null @@ -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 - } -} diff --git a/readeck/Data/Repository/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift index 92c60ce..cd30e8e 100644 --- a/readeck/Data/Repository/SettingsRepository.swift +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -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 { diff --git a/readeck/Domain/Model/Theme.swift b/readeck/Domain/Model/Theme.swift new file mode 100644 index 0000000..8fa6ebf --- /dev/null +++ b/readeck/Domain/Model/Theme.swift @@ -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" + } + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/SaveSettingsUseCase.swift b/readeck/Domain/UseCase/SaveSettingsUseCase.swift index f8c6176..bcfb8e5 100644 --- a/readeck/Domain/UseCase/SaveSettingsUseCase.swift +++ b/readeck/Domain/UseCase/SaveSettingsUseCase.swift @@ -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) + ) + } } diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 196a0db..6b76313 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -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) + } } } } diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift index caf29ef..cae37b9 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -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 diff --git a/readeck/UI/Components/RButton.swift b/readeck/UI/Components/RButton.swift new file mode 100644 index 0000000..d4cacbf --- /dev/null +++ b/readeck/UI/Components/RButton.swift @@ -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: 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) + } +} diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index 5f67c9b..be861ba 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -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 { diff --git a/readeck/UI/Labels/LabelsView.swift b/readeck/UI/Labels/LabelsView.swift index 23d47db..2acc8e7 100644 --- a/readeck/UI/Labels/LabelsView.swift +++ b/readeck/UI/Labels/LabelsView.swift @@ -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) { + 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) + } + } +} diff --git a/readeck/UI/Labels/LabelsViewModel.swift b/readeck/UI/Labels/LabelsViewModel.swift index 2cce2d4..1966e8f 100644 --- a/readeck/UI/Labels/LabelsViewModel.swift +++ b/readeck/UI/Labels/LabelsViewModel.swift @@ -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 { diff --git a/readeck/UI/Menu/PadSidebarView.swift b/readeck/UI/Menu/PadSidebarView.swift index 6fc8122..3554a61 100644 --- a/readeck/UI/Menu/PadSidebarView.swift +++ b/readeck/UI/Menu/PadSidebarView.swift @@ -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) } diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index 861a6d5..f62ed1a 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -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)) } } } diff --git a/readeck/UI/Models/AppSettings.swift b/readeck/UI/Models/AppSettings.swift new file mode 100644 index 0000000..aecf792 --- /dev/null +++ b/readeck/UI/Models/AppSettings.swift @@ -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 + } +} diff --git a/readeck/UI/SpeechPlayer/PlayerUIState.swift b/readeck/UI/Models/PlayerUIState.swift similarity index 100% rename from readeck/UI/SpeechPlayer/PlayerUIState.swift rename to readeck/UI/Models/PlayerUIState.swift diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index dd9cf60..e1741ae 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -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 diff --git a/readeck/UI/Settings/SettingsGeneralView.swift b/readeck/UI/Settings/SettingsGeneralView.swift index 8e9da56..f7d9afc 100644 --- a/readeck/UI/Settings/SettingsGeneralView.swift +++ b/readeck/UI/Settings/SettingsGeneralView.swift @@ -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() diff --git a/readeck/UI/Settings/SettingsGeneralViewModel.swift b/readeck/UI/Settings/SettingsGeneralViewModel.swift index ad9563b..10b12c0 100644 --- a/readeck/UI/Settings/SettingsGeneralViewModel.swift +++ b/readeck/UI/Settings/SettingsGeneralViewModel.swift @@ -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" diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index 9cef324..6534760 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -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() diff --git a/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift index d45f6c1..648d705 100644 --- a/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift +++ b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift @@ -4,6 +4,7 @@ struct GlobalPlayerContainerView: 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: 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) diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerView.swift b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift index 62d10c3..a8353ba 100644 --- a/readeck/UI/SpeechPlayer/SpeechPlayerView.swift +++ b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift @@ -38,6 +38,11 @@ struct SpeechPlayerView: View { } } ) + .onAppear() { + Task { + await viewModel.setup() + } + } } } diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift index 12a6c6b..95730f9 100644 --- a/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift +++ b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift @@ -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() @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() } } diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 9a152e9..2df98fb 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -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 + } } } diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index 22b2a9e..4f1c892 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -1,9 +1,7 @@ - - - +