854 lines
32 KiB
Swift
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 }
|
|
}
|