Files
FeelAloud/FeelAloud/SettingsView.swift
2026-07-22 22:54:03 +02:00

854 lines
32 KiB
Swift

import AppIntents
import AuthenticationServices
import FoundationModels
import SwiftData
import SwiftUI
import UserNotifications
#if canImport(UIKit)
import UIKit
#endif
struct SettingsView: View {
@Environment(\.openURL) private var openURL
@Environment(PremiumStore.self) private var premiumStore
@Query(sort: \MoodEntry.createdAt) private var entries: [MoodEntry]
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
@AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data()
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
@AppStorage(UserProfileStore.accountEmailKey) private var accountEmail = ""
@AppStorage(AccountBackendStore.backendURLKey) private var backendURL = ""
@AppStorage("snapshotRemindersEnabled") private var remindersEnabled = false
@AppStorage("snapshotReminderCount") private var reminderCount = 3
@AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60
@AppStorage("snapshotReminderTime2") private var reminderTime2 = 15 * 60
@AppStorage("snapshotReminderTime3") private var reminderTime3 = 20 * 60
@AppStorage("appLockEnabled") private var appLockEnabled = false
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue
@AppStorage("forceLightAppearance") private var forceLightAppearance = true
@AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false
@AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = false
@State private var notificationStatus = "Wird geprüft …"
@State private var notificationMessage: String?
@State private var iCloudStatus = "Noch nicht aktiviert"
@State private var exportFile: SettingsExportFile?
@State private var exportError: String?
@State private var appLockMessage: String?
@State private var customCategories: [DiaryCategory] = []
@State private var newCategoryName = ""
@State private var profileMessage: String?
@State private var showsLogoutConfirmation = false
var body: some View {
NavigationStack {
List {
Section("Erfassung") {
NavigationLink {
remindersSettings
} label: {
settingsNavigationRow(
title: "Momentaufnahmen",
subtitle: remindersEnabled ? "\(safeReminderCount) Erinnerungen pro Tag" : "Erinnerungen und Status",
systemImage: "bell.badge"
)
}
NavigationLink {
categoriesSettings
} label: {
settingsNavigationRow(
title: "Kategorien",
subtitle: "\(customCategories.count) eigene Kategorien",
systemImage: "tag"
)
}
}
Section("Privatsphäre") {
NavigationLink {
profileSettings
} label: {
settingsNavigationRow(
title: "Profil",
subtitle: displayName.isEmpty ? "Account, Premium und Logout" : displayName,
systemImage: "person.crop.circle"
)
}
NavigationLink {
privacySettings
} label: {
settingsNavigationRow(
title: "Datenschutz",
subtitle: "App-Sperre und lokale KI",
systemImage: "lock.shield"
)
}
NavigationLink {
syncSettings
} label: {
settingsNavigationRow(
title: "iCloud Sync",
subtitle: "Account und Synchronisation",
systemImage: "icloud"
)
}
}
Section("App") {
NavigationLink {
appearanceSettings
} label: {
settingsNavigationRow(
title: "Darstellung",
subtitle: AppColorProfile(rawValue: colorProfileRaw)?.name ?? "Farbprofil",
systemImage: "paintpalette"
)
}
NavigationLink {
exportSettings
} label: {
settingsNavigationRow(
title: "Daten und Export",
subtitle: "Reports, CSV und Speicherstatus",
systemImage: "square.and.arrow.up"
)
}
NavigationLink {
premiumSettings
} label: {
settingsNavigationRow(
title: "Unterstützen",
subtitle: premiumFeaturesEnabled ? "Bonusfunktionen aktiv" : "Bonusfunktionen testen",
systemImage: "heart"
)
}
}
Section("System") {
NavigationLink {
shortcutsSettings
} label: {
settingsNavigationRow(
title: "Kurzbefehle",
subtitle: "Action Button einrichten",
systemImage: "button.programmable"
)
}
NavigationLink {
appInfoSettings
} label: {
settingsNavigationRow(
title: "App-Info",
subtitle: "Hinweise zur Nutzung",
systemImage: "info.circle"
)
}
}
}
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.inline)
}
.appKeyboardBehavior()
.task {
customCategories = DiaryCategoryStore.load()
await refreshNotificationStatus()
if remindersEnabled {
await configureReminders(requestAuthorization: false)
}
}
.sheet(item: $exportFile) { file in
ShareSheet(items: [file.url])
}
.alert("Export nicht möglich", isPresented: exportErrorBinding) {
Button("OK", role: .cancel) { }
} message: {
Text(exportError ?? "Unbekannter Fehler")
}
.alert("Profil", isPresented: profileMessageBinding) {
Button("OK", role: .cancel) { }
} message: {
Text(profileMessage ?? "")
}
.confirmationDialog("Logout", isPresented: $showsLogoutConfirmation, titleVisibility: .visible) {
Button("Lokales Profil abmelden", role: .destructive) {
logoutProfile()
}
Button("Abbrechen", role: .cancel) { }
} message: {
Text("Deine Tagebucheinträge und Momentaufnahmen bleiben erhalten. Nur Profilangaben und der abgeschlossene Onboarding-Status werden zurückgesetzt.")
}
}
private var profileSettings: some View {
List {
Section("Profil") {
HStack(spacing: 12) {
profileAvatar
VStack(alignment: .leading, spacing: 3) {
Text(displayName.isEmpty ? "Ohne Namen" : displayName)
.font(.headline)
Text("Lokales Feel Aloud Profil")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
LabeledContent("Account", value: currentAccountProvider.displayName)
if !accountEmail.isEmpty {
LabeledContent("E-Mail", value: accountEmail)
}
}
Section("Anmelden") {
TextField("Backend-URL", text: $backendURL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
handleAppleSignIn(result)
}
.signInWithAppleButtonStyle(.black)
.frame(height: 46)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
Button {
profileMessage = "Das Backend unterstützt Google ID-Token bereits über /auth/google. In der iOS-App fehlt dafür noch die GoogleSignIn SDK-Konfiguration mit deiner Google Client ID."
} label: {
Label("Mit Google anmelden", systemImage: "g.circle")
}
Button {
profileMessage = "Das Backend unterstützt E-Mail/Passwort bereits. Als nächster Schritt braucht die App noch eine eigene Maske für Registrierung, Login und Passwortwechsel gegen die Backend-Endpoints."
} label: {
Label("Mit E-Mail anmelden", systemImage: "envelope")
}
}
Section("Premium") {
Label(
premiumFeaturesEnabled ? "Premium aktiv" : "Premium nicht aktiv",
systemImage: premiumFeaturesEnabled ? "checkmark.seal.fill" : "heart"
)
.foregroundStyle(premiumFeaturesEnabled ? .green : .secondary)
Button {
openPremiumManagement()
} label: {
Label("Premium verwalten", systemImage: "creditcard")
}
Button {
Task {
await premiumStore.restorePurchases()
premiumFeaturesEnabled = premiumStore.premiumFeaturesEnabled
}
} label: {
Label("Kauf wiederherstellen", systemImage: "arrow.clockwise")
}
}
Section("Zugang") {
Button {
profileMessage = "Passwort ändern ist nur für E-Mail-Accounts sinnvoll. Dafür braucht FeelAloud zuerst einen echten Auth-Server mit sicherer Passwortspeicherung und Reset-Mail-Funktion."
} label: {
Label("Passwort ändern", systemImage: "key")
}
Button(role: .destructive) {
showsLogoutConfirmation = true
} label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.navigationTitle("Profil")
.navigationBarTitleDisplayMode(.inline)
}
private var remindersSettings: some View {
List {
Section("Momentaufnahmen") {
Toggle("Tägliche Erinnerungen", isOn: reminderToggle)
if remindersEnabled {
Picker("Anzahl pro Tag", selection: reminderCountBinding) {
Text("2-mal").tag(2)
Text("3-mal").tag(3)
}
.pickerStyle(.segmented)
ForEach(0..<safeReminderCount, id: \.self) { index in
DatePicker(
"Erinnerung \(index + 1)",
selection: timeBinding(for: index),
displayedComponents: .hourAndMinute
)
}
}
LabeledContent("Mitteilungen", value: notificationStatus)
if let notificationMessage {
Text(notificationMessage)
.font(.footnote)
.foregroundStyle(.orange)
}
Text("Beim Antippen öffnet sich direkt die Auswahl mit fünf Gesichtern. Die Erinnerungen werden lokal vom iPhone geplant.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Momentaufnahmen")
.navigationBarTitleDisplayMode(.inline)
}
private var categoriesSettings: some View {
List {
Section("Kategorien") {
LabeledContent("Voreingestellt", value: "Stimmung, Stress, Sozialkontakte")
if customCategories.isEmpty {
Text("Eigene Kategorien erscheinen bei neuen Einträgen zusätzlich als Schalter.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(customCategories) { category in
Text(category.name)
.swipeActions {
Button(role: .destructive) {
deleteCategory(category)
} label: {
Label("Löschen", systemImage: "trash")
}
}
}
}
HStack {
TextField("Neue Kategorie", text: $newCategoryName)
.submitLabel(.done)
Button("Hinzufügen") {
addCategory()
}
.disabled(newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.navigationTitle("Kategorien")
.navigationBarTitleDisplayMode(.inline)
}
private var privacySettings: some View {
List {
Section("Datenschutz") {
Toggle("App mit Face ID sperren", isOn: appLockToggle)
.disabled(!AppLockAvailability.canAuthenticate || AppLockAvailability.requiresMissingFaceIDDescription)
LabeledContent("Sperre", value: AppLockAvailability.settingsStatus)
if let appLockMessage {
Text(appLockMessage)
.font(.footnote)
.foregroundStyle(.orange)
}
Text("Bei aktivierter Sperre fragt FeelAloud beim Öffnen und nach dem Verlassen der App nach Face ID, Touch ID oder Gerätecode.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Lokale KI") {
LabeledContent("Apple-Modell") {
Label(
aiStatus,
systemImage: aiAvailable
? "checkmark.circle.fill"
: "exclamationmark.triangle.fill"
)
.foregroundStyle(aiAvailable ? .green : .orange)
}
Text("Sprache und Tagebuchtext werden auf diesem iPhone verarbeitet und nicht an einen eigenen Server gesendet.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Datenschutz")
.navigationBarTitleDisplayMode(.inline)
}
private var syncSettings: some View {
List {
Section("iCloud Sync") {
LabeledContent("Account", value: iCloudStatus)
Text("Momentaufnahmen und Tagebucheinträge werden über die private iCloud-Datenbank dieses Apple-Accounts synchronisiert, sobald iCloud Drive und CloudKit für die App aktiviert sind.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("iCloud Sync")
.navigationBarTitleDisplayMode(.inline)
}
private var appearanceSettings: some View {
List {
Section("Darstellung") {
Picker("Farbprofil", selection: $colorProfileRaw) {
ForEach(AppColorProfile.allCases) { profile in
HStack {
Circle()
.fill(profile.accent)
.frame(width: 14, height: 14)
Text(profile.name)
}
.tag(profile.rawValue)
}
}
Toggle("Helle Darstellung erzwingen", isOn: $forceLightAppearance)
Text("Die Profile nutzen ruhige Pastellfarben und ändern die Akzentfarbe der App.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Darstellung")
.navigationBarTitleDisplayMode(.inline)
}
private var exportSettings: some View {
List {
Section("Daten und Export") {
if swiftDataUsedFallback {
Label("Temporärer Datenspeicher aktiv", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Der dauerhafte Datenspeicher konnte beim Start nicht geöffnet werden. Neue Daten werden in dieser Sitzung nur temporär gehalten.")
.font(.footnote)
.foregroundStyle(.orange)
}
Label("Diagramm und Excel-Report im Tab Verlauf", systemImage: "chart.xyaxis.line")
Button {
exportCSV()
} label: {
Label("Altes Numbers-CSV exportieren", systemImage: "square.and.arrow.up")
}
.disabled(entries.isEmpty)
Text("Der Excel-Report enthält den gewählten Zeitraum, den Durchschnitt, alle Momentaufnahmen und die ausführlichen Tagebucheinträge.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Daten und Export")
.navigationBarTitleDisplayMode(.inline)
}
private var premiumSettings: some View {
List {
Section("Unterstützen") {
Toggle("Premium features", isOn: premiumFeaturesToggle)
Label(
premiumFeaturesEnabled ? "Bonusfunktionen aktiv" : "Bonusfunktionen gesperrt",
systemImage: premiumFeaturesEnabled ? "checkmark.seal.fill" : "heart"
)
.foregroundStyle(premiumFeaturesEnabled ? .green : .secondary)
Text("Testphase: Der Schalter simuliert den späteren Unterstützen-Kauf. Projektmappen und der Therapeuten-Excel-Export sind als Bonusfunktionen vorgesehen.")
.font(.footnote)
.foregroundStyle(.secondary)
Button {
Task {
await premiumStore.restorePurchases()
premiumFeaturesEnabled = premiumStore.premiumFeaturesEnabled
}
} label: {
Label("Käufe wiederherstellen", systemImage: "arrow.clockwise")
}
}
}
.navigationTitle("Unterstützen")
.navigationBarTitleDisplayMode(.inline)
}
private var shortcutsSettings: some View {
List {
Section("Action Button") {
ShortcutsLink()
.shortcutsLinkStyle(.automaticOutline)
Label("Kurzbefehle → FeelAloud → Eintrag aufnehmen", systemImage: "button.programmable")
Text("Öffne FeelAloud einmal über den Button oben in der Kurzbefehle-App. Danach kann dieser Kurzbefehl in Einstellungen → Aktionstaste → Kurzbefehl ausgewählt werden.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Kurzbefehle")
.navigationBarTitleDisplayMode(.inline)
}
private var appInfoSettings: some View {
List {
Section("Hinweis") {
Text("Die App dient der persönlichen Dokumentation und stellt keine medizinische Diagnose oder Behandlung dar.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("App-Info")
.navigationBarTitleDisplayMode(.inline)
}
private func settingsNavigationRow(title: String, subtitle: String, systemImage: String) -> some View {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.headline)
.foregroundStyle(.tint)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private var currentAccountProvider: AccountProvider {
AccountProvider(rawValue: accountProviderRaw) ?? .local
}
private var profileAvatar: some View {
Group {
#if canImport(UIKit)
if let image = UIImage(data: profileImageData) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
Image(systemName: "person.crop.circle.fill")
.font(.title)
.foregroundStyle(.secondary)
}
#else
Image(systemName: "person.crop.circle.fill")
.font(.title)
.foregroundStyle(.secondary)
#endif
}
.frame(width: 48, height: 48)
.background(Color(.secondarySystemGroupedBackground), in: Circle())
.clipShape(Circle())
}
private var aiAvailable: Bool {
SystemLanguageModel.default.availability == .available
}
private var aiStatus: String {
aiAvailable ? "Bereit" : "Nicht bereit"
}
private var appLockToggle: Binding<Bool> {
Binding(
get: { appLockEnabled },
set: { enabled in
appLockMessage = nil
guard enabled else {
appLockEnabled = false
return
}
guard AppLockAvailability.canAuthenticate else {
appLockMessage = "Auf diesem Gerät ist keine lokale Entsperrung eingerichtet."
appLockEnabled = false
return
}
guard !AppLockAvailability.requiresMissingFaceIDDescription else {
appLockMessage = "Face ID braucht noch den Info-Plist-Eintrag Privacy - Face ID Usage Description."
appLockEnabled = false
return
}
appLockEnabled = true
}
)
}
private var premiumFeaturesToggle: Binding<Bool> {
Binding(
get: { premiumFeaturesEnabled },
set: { enabled in
premiumStore.setPremiumEnabled(enabled)
premiumFeaturesEnabled = enabled
}
)
}
private var reminderToggle: Binding<Bool> {
Binding(
get: { remindersEnabled },
set: { enabled in
remindersEnabled = enabled
Task {
await configureReminders(requestAuthorization: enabled)
}
}
)
}
private var reminderCountBinding: Binding<Int> {
Binding(
get: { reminderCount },
set: { count in
reminderCount = min(max(count, 2), 3)
Task { await rescheduleIfEnabled() }
}
)
}
private func timeBinding(for index: Int) -> Binding<Date> {
Binding(
get: {
let value = storedMinuteValue(for: index)
return Calendar.current.date(
bySettingHour: value / 60,
minute: value % 60,
second: 0,
of: .now
) ?? .now
},
set: { date in
let components = Calendar.current.dateComponents([.hour, .minute], from: date)
let value = (components.hour ?? 0) * 60 + (components.minute ?? 0)
setStoredMinuteValue(value, for: index)
Task { await rescheduleIfEnabled() }
}
)
}
private func storedMinuteValue(for index: Int) -> Int {
switch index {
case 0: reminderTime1
case 1: reminderTime2
default: reminderTime3
}
}
private func setStoredMinuteValue(_ value: Int, for index: Int) {
switch index {
case 0: reminderTime1 = value
case 1: reminderTime2 = value
default: reminderTime3 = value
}
}
private var activeReminderTimes: [Int] {
Array([reminderTime1, reminderTime2, reminderTime3].prefix(safeReminderCount))
}
private var safeReminderCount: Int {
min(max(reminderCount, 2), 3)
}
@MainActor
private func configureReminders(requestAuthorization: Bool) async {
notificationMessage = nil
guard remindersEnabled else {
SnapshotNotificationScheduler.cancelDailyReminders()
await refreshNotificationStatus()
return
}
do {
try await SnapshotNotificationScheduler.scheduleDailyReminders(
minuteValues: activeReminderTimes,
requestAuthorization: requestAuthorization
)
} catch {
remindersEnabled = false
notificationMessage = error.localizedDescription
}
await refreshNotificationStatus()
}
@MainActor
private func rescheduleIfEnabled() async {
guard remindersEnabled else { return }
await configureReminders(requestAuthorization: false)
}
@MainActor
private func refreshNotificationStatus() async {
let status = await SnapshotNotificationScheduler.authorizationStatus()
switch status {
case .notDetermined:
notificationStatus = "Noch nicht gefragt"
case .denied:
notificationStatus = "Nicht erlaubt"
case .authorized, .provisional, .ephemeral:
notificationStatus = "Erlaubt"
@unknown default:
notificationStatus = "Unbekannt"
}
}
private var exportErrorBinding: Binding<Bool> {
Binding(
get: { exportError != nil },
set: { if !$0 { exportError = nil } }
)
}
private var profileMessageBinding: Binding<Bool> {
Binding(
get: { profileMessage != nil },
set: { if !$0 { profileMessage = nil } }
)
}
private func exportCSV() {
do {
let url = try CSVExporter.createFile(entries: entries)
guard FileManager.default.fileExists(atPath: url.path) else {
exportError = "Die CSV-Datei konnte nicht erstellt werden."
return
}
exportFile = SettingsExportFile(url: url)
} catch {
exportError = error.localizedDescription
}
}
private func addCategory() {
let name = newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
guard !customCategories.contains(where: { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame }) else {
newCategoryName = ""
return
}
customCategories.append(DiaryCategory(name: name))
customCategories.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
DiaryCategoryStore.save(customCategories)
newCategoryName = ""
}
private func deleteCategory(_ category: DiaryCategory) {
customCategories.removeAll { $0.id == category.id }
DiaryCategoryStore.save(customCategories)
}
private func openPremiumManagement() {
guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
openURL(url)
}
private func logoutProfile() {
displayName = ""
profileImageData = Data()
accountEmail = ""
accountProviderRaw = AccountProvider.local.rawValue
UserProfileStore.clearAccount()
hasCompletedOnboarding = false
}
private func handleAppleSignIn(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
profileMessage = "Apple hat keine gültigen Accountdaten zurückgegeben."
return
}
guard let identityTokenData = credential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
profileMessage = "Apple hat kein Identity Token zurückgegeben."
return
}
UserProfileStore.saveAppleAccount(
userID: credential.user,
email: credential.email,
fullName: credential.fullName
)
accountProviderRaw = AccountProvider.apple.rawValue
if let email = credential.email, !email.isEmpty {
accountEmail = email
}
let formatter = PersonNameComponentsFormatter()
let appleName = credential.fullName.map { formatter.string(from: $0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? ""
if !appleName.isEmpty {
displayName = appleName
}
Task {
await syncAppleAccountWithBackend(
identityToken: identityToken,
authorizationCode: credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) },
email: credential.email,
fullName: credential.fullName
)
}
case .failure(let error):
profileMessage = "Apple Login konnte nicht abgeschlossen werden: \(error.localizedDescription)"
}
}
@MainActor
private func syncAppleAccountWithBackend(
identityToken: String,
authorizationCode: String?,
email: String?,
fullName: PersonNameComponents?
) async {
guard AccountBackendStore.backendURL != nil else {
profileMessage = "Apple Account wurde lokal verknüpft. Trage eine Backend-URL ein, um ihn serverseitig zu synchronisieren."
return
}
do {
let formatter = PersonNameComponentsFormatter()
let session = try await AccountBackendClient().signInWithApple(
identityToken: identityToken,
authorizationCode: authorizationCode,
email: email,
displayName: fullName.map { formatter.string(from: $0) }
)
AccountBackendStore.save(session: session)
accountProviderRaw = AccountProvider.apple.rawValue
accountEmail = session.user.email ?? accountEmail
displayName = session.user.displayName
profileMessage = "Apple Account wurde mit dem Backend synchronisiert."
} catch {
profileMessage = "Apple Account wurde lokal verknüpft, aber das Backend konnte nicht synchronisieren: \(error.localizedDescription)"
}
}
}
private struct SettingsExportFile: Identifiable {
let url: URL
var id: String { url.absoluteString }
}