diff --git a/FeelAloud/AppTheme.swift b/FeelAloud/AppTheme.swift index 0e4895f..b83dbc5 100644 --- a/FeelAloud/AppTheme.swift +++ b/FeelAloud/AppTheme.swift @@ -247,6 +247,46 @@ enum PremiumPurchaseState: Equatable { } } +struct FeelAloudLogoMark: View { + @Environment(\.appColorProfile) private var colorProfile + + var size: CGFloat = 52 + var showsBackground: Bool = true + + var body: some View { + ZStack { + if showsBackground { + RoundedRectangle(cornerRadius: max(8, size * 0.23), style: .continuous) + .fill( + LinearGradient( + colors: [ + colorProfile.accent.opacity(0.92), + colorProfile.secondary.opacity(0.76), + Color(red: 0.95, green: 0.68, blue: 0.74).opacity(0.74) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + Image(systemName: "heart.fill") + .font(.system(size: size * 0.58, weight: .semibold)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.10), radius: 3, y: 2) + + Image(systemName: "waveform") + .font(.system(size: size * 0.25, weight: .bold)) + .foregroundStyle(colorProfile.accent) + .offset(y: size * 0.01) + .blendMode(.plusDarker) + } + .frame(width: size, height: size) + .shadow(color: colorProfile.accent.opacity(showsBackground ? 0.20 : 0), radius: size * 0.22, y: size * 0.10) + .accessibilityHidden(true) + } +} + struct PremiumPaywallView: View { let title: String let message: String diff --git a/FeelAloud/CSVExporter.swift b/FeelAloud/CSVExporter.swift index 69239e4..db14296 100644 --- a/FeelAloud/CSVExporter.swift +++ b/FeelAloud/CSVExporter.swift @@ -12,7 +12,9 @@ enum CSVExporter { "Leidensdruck", "Gegenmaßnahmen", "GegenmaßnahmeAktivität", - "Stress" + "Stress", + "InTherapieBesprechen", + "TherapeutenNotizen" ].joined(separator: ";") let formatter = ISO8601DateFormatter() @@ -29,7 +31,9 @@ enum CSVExporter { entry.musicInHead ? entry.distressRaw : "", entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : "", entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : "", - entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein") + entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein"), + entry.discussInTherapy ? "Ja" : "Nein", + entry.therapistNotes ] return values.map(escaped).joined(separator: ";") } @@ -41,7 +45,7 @@ enum CSVExporter { let date = filenameFormatter.string(from: .now) let url = FileManager.default.temporaryDirectory .appendingPathComponent("FeelAloud-\(date).csv") - try content.write(to: url, atomically: true, encoding: .utf8) + try Data(content.utf8).write(to: url, options: [.atomic, .completeFileProtection]) return url } diff --git a/FeelAloud/EntryFlowView.swift b/FeelAloud/EntryFlowView.swift index 75c6ade..a3c920e 100644 --- a/FeelAloud/EntryFlowView.swift +++ b/FeelAloud/EntryFlowView.swift @@ -188,6 +188,12 @@ struct EntryFlowView: View { Section("Kategorien") { Toggle("Stress", isOn: $draft.stress) Toggle("Sozialkontakt", isOn: $draft.socialContact) + Toggle("In Therapie besprechen", isOn: $draft.discussInTherapy) + + if draft.discussInTherapy { + TextField("Zusatznotizen für den Therapeuten", text: $draft.therapistNotes, axis: .vertical) + .lineLimit(3...6) + } ForEach(customCategories) { category in Toggle(category.name, isOn: customCategoryBinding(for: category)) diff --git a/FeelAloud/HistoryView.swift b/FeelAloud/HistoryView.swift index 61bfd73..b666b50 100644 --- a/FeelAloud/HistoryView.swift +++ b/FeelAloud/HistoryView.swift @@ -1060,20 +1060,16 @@ struct EntryDetailView: View { LabeledContent("Stimmung", value: entry.mood.rawValue) LabeledContent("Stress", value: entry.stress ? "Ja" : "Nein") LabeledContent("Sozialkontakt", value: entry.socialContact ? "Ja" : "Nein") - LabeledContent("Therapie", value: entry.discussInTherapy ? "Markiert" : "Nicht markiert") + Toggle( + "In Therapie besprechen", + isOn: therapyDiscussionBinding + ) + } - Button { - entry.discussInTherapy.toggle() - try? modelContext.save() - } label: { - Label( - entry.discussInTherapy - ? "Nicht mehr in Therapie besprechen" - : "In Therapie besprechen", - systemImage: entry.discussInTherapy - ? "person.2.wave.2.fill" - : "person.2.wave.2" - ) + if entry.discussInTherapy { + Section("Zusatznotizen für den Therapeuten") { + TextEditor(text: therapistNotesBinding) + .frame(minHeight: 100) } } @@ -1099,8 +1095,7 @@ struct EntryDetailView: View { .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button { - entry.discussInTherapy.toggle() - try? modelContext.save() + setTherapyDiscussion(!entry.discussInTherapy) } label: { Image(systemName: entry.discussInTherapy ? "person.2.wave.2.fill" : "person.2.wave.2") } @@ -1118,6 +1113,31 @@ struct EntryDetailView: View { customCategories = DiaryCategoryStore.load() } } + + private var therapyDiscussionBinding: Binding { + Binding( + get: { entry.discussInTherapy }, + set: { setTherapyDiscussion($0) } + ) + } + + private var therapistNotesBinding: Binding { + Binding( + get: { entry.therapistNotes }, + set: { + entry.therapistNotes = $0 + try? modelContext.save() + } + ) + } + + private func setTherapyDiscussion(_ isEnabled: Bool) { + entry.discussInTherapy = isEnabled + if !isEnabled { + entry.therapistNotes = "" + } + try? modelContext.save() + } } private struct EditEntryView: View { @@ -1138,6 +1158,7 @@ private struct EditEntryView: View { @State private var stress: Bool @State private var transcript: String @State private var discussInTherapy: Bool + @State private var therapistNotes: String @State private var customCategories: [DiaryCategory] @State private var customCategoryValues: [String: Bool] @@ -1155,6 +1176,7 @@ private struct EditEntryView: View { _stress = State(initialValue: entry.stress) _transcript = State(initialValue: entry.transcript) _discussInTherapy = State(initialValue: entry.discussInTherapy) + _therapistNotes = State(initialValue: entry.therapistNotes) let categories = DiaryCategoryStore.load() _customCategories = State(initialValue: categories) _customCategoryValues = State(initialValue: entry.customCategoryValues) @@ -1175,6 +1197,11 @@ private struct EditEntryView: View { Toggle("Sozialkontakt", isOn: $socialContact) Toggle("Stress", isOn: $stress) Toggle("In Therapie besprechen", isOn: $discussInTherapy) + + if discussInTherapy { + TextField("Zusatznotizen für den Therapeuten", text: $therapistNotes, axis: .vertical) + .lineLimit(3...6) + } } if !customCategories.isEmpty { @@ -1223,6 +1250,9 @@ private struct EditEntryView: View { entry.stress = stress entry.transcript = transcript.trimmingCharacters(in: .whitespacesAndNewlines) entry.discussInTherapy = discussInTherapy + entry.therapistNotes = discussInTherapy + ? therapistNotes.trimmingCharacters(in: .whitespacesAndNewlines) + : "" entry.customCategoryValuesJSON = DiaryCategoryStore.encodeValues(customCategoryValues) try? modelContext.save() diff --git a/FeelAloud/HomeView.swift b/FeelAloud/HomeView.swift index 124ef92..ed45856 100644 --- a/FeelAloud/HomeView.swift +++ b/FeelAloud/HomeView.swift @@ -6,6 +6,8 @@ struct HomeView: View { @Environment(\.appColorProfile) private var colorProfile @Query(sort: \MoodEntry.createdAt, order: .reverse) private var entries: [MoodEntry] @Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot] + @AppStorage(UserProfileStore.displayNameKey) private var displayName = "" + @AppStorage(UserProfileStore.healthAssociationKey) private var healthAssociation = "" @State private var headerMessage = HomeHeaderMessage.random() private var recentEntries: [MoodEntry] { @@ -33,14 +35,20 @@ struct HomeView: View { VStack(spacing: 18) { appHeader welcomeCard + if !trimmedHealthAssociation.isEmpty { + personalAnchorCard + } snapshotButton if showsSafetySupportCard { safetySupportCard } + todayCompanionCard weekOverview if let latest = entries.first { latestEntry(latest) + } else { + firstEntryHint } } .padding() @@ -52,14 +60,10 @@ struct HomeView: View { private var appHeader: some View { HStack(spacing: 12) { - Image(systemName: "heart.text.square.fill") - .font(.title2) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - .background(colorProfile.accent, in: RoundedRectangle(cornerRadius: 8)) + FeelAloudLogoMark(size: 46) VStack(alignment: .leading, spacing: 2) { - Text("Feel Aloud") + Text(greetingTitle) .font(.title3.bold()) Text(headerMessage.text) .font(.caption) @@ -74,6 +78,45 @@ struct HomeView: View { } } + private var greetingTitle: String { + let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + guard let firstName = trimmedName.split(separator: " ").first else { + return "Feel Aloud" + } + return "Hallo \(firstName)" + } + + private var trimmedHealthAssociation: String { + healthAssociation.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var personalAnchorCard: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "sparkles") + .font(.headline) + .foregroundStyle(colorProfile.accent) + .frame(width: 38, height: 38) + .background(.background.opacity(0.62), in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text("Dein Gesundheitsbild") + .font(.subheadline.weight(.semibold)) + Text("Für dich bedeutet Gesundheit: \(trimmedHealthAssociation)") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(14) + .background(colorProfile.softBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke(colorProfile.accent.opacity(0.10), lineWidth: 1) + } + } + private var snapshotButton: some View { Button { router.presentSnapshot(source: .manual) @@ -81,6 +124,8 @@ struct HomeView: View { HStack(spacing: 12) { Text("🙂") .font(.title2) + .frame(width: 42, height: 42) + .background(colorProfile.softBackground, in: Circle()) VStack(alignment: .leading, spacing: 2) { Text("Momentaufnahme") .font(.headline) @@ -93,7 +138,11 @@ struct HomeView: View { .foregroundStyle(.secondary) } .padding(16) - .background(.background, in: RoundedRectangle(cornerRadius: 18)) + .background(.background.opacity(0.94), in: RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke(colorProfile.accent.opacity(0.10), lineWidth: 1) + } } .buttonStyle(.plain) } @@ -117,8 +166,22 @@ struct HomeView: View { Spacer(minLength: 0) } - .padding(16) - .background(.background, in: RoundedRectangle(cornerRadius: 18)) + .padding(16) + .background( + LinearGradient( + colors: [ + colorProfile.softBackground, + Color(red: 1.0, green: 0.93, blue: 0.95).opacity(0.55) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 18) + ) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke(colorProfile.accent.opacity(0.12), lineWidth: 1) + } } private var welcomeCard: some View { @@ -126,18 +189,17 @@ struct HomeView: View { router.presentNewEntry(startRecording: false) } label: { HStack(spacing: 15) { - Image(systemName: "waveform.and.mic") - .font(.system(size: 34)) - .foregroundStyle(.white) - .frame(width: 62, height: 62) - .background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 18)) + FeelAloudLogoMark(size: 64, showsBackground: false) + .frame(width: 64, height: 64) + .background(.white.opacity(0.20), in: RoundedRectangle(cornerRadius: 18)) VStack(alignment: .leading, spacing: 5) { Text("Wie geht es dir gerade?") .font(.title3.bold()) - Text("Erzähl einfach frei. Dein iPhone sortiert die Angaben lokal für dich.") + Text("Sprich oder schreibe, was gerade da ist. Der Rest wird behutsam einsortiert.") .font(.subheadline) .foregroundStyle(.white.opacity(0.86)) + .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) Image(systemName: "arrow.right.circle.fill") @@ -148,12 +210,17 @@ struct HomeView: View { .padding(18) .background( LinearGradient( - colors: colorProfile.gradient, + colors: [ + colorProfile.accent, + colorProfile.secondary, + Color(red: 0.93, green: 0.58, blue: 0.67) + ], startPoint: .topLeading, endPoint: .bottomTrailing ), in: RoundedRectangle(cornerRadius: 24) ) + .shadow(color: colorProfile.accent.opacity(0.20), radius: 18, y: 10) } .buttonStyle(.plain) .accessibilityHint("Startet eine lokale Sprachaufnahme") @@ -186,6 +253,56 @@ struct HomeView: View { .background(.background, in: RoundedRectangle(cornerRadius: 18)) } + private var todayCompanionCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Heute kurz prüfen") + .font(.headline) + + HStack(spacing: 10) { + todayStep( + symbol: entriesContainToday ? "checkmark.circle.fill" : "mic.fill", + title: entriesContainToday ? "Eintrag da" : "Eintrag", + subtitle: entriesContainToday ? "Heute festgehalten" : "Gedanken sortieren" + ) + todayStep( + symbol: snapshotsContainToday ? "checkmark.circle.fill" : "face.smiling", + title: snapshotsContainToday ? "Check da" : "Check", + subtitle: snapshotsContainToday ? "Moment getrackt" : "Stimmung antippen" + ) + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 18)) + } + + private var entriesContainToday: Bool { + entries.contains { Calendar.current.isDateInToday($0.createdAt) } + } + + private var snapshotsContainToday: Bool { + snapshots.contains { Calendar.current.isDateInToday($0.createdAt) } + } + + private func todayStep(symbol: String, title: String, subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: symbol) + .font(.headline) + .foregroundStyle(colorProfile.accent) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(colorProfile.softBackground.opacity(0.54), in: RoundedRectangle(cornerRadius: 14)) + } + private func metric(value: String, label: String, symbol: String) -> some View { VStack(spacing: 6) { Image(systemName: symbol) @@ -233,20 +350,33 @@ struct HomeView: View { .background(.background, in: RoundedRectangle(cornerRadius: 18)) } + private var firstEntryHint: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Noch kein Tagebucheintrag") + .font(.headline) + Text("Der erste Eintrag muss nicht vollständig sein. Ein Gedanke reicht, damit Feel Aloud anfangen kann, Muster sichtbar zu machen.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .background(.background.opacity(0.86), in: RoundedRectangle(cornerRadius: 18)) + } + private var averageSnapshotText: String { - guard !recentSnapshots.isEmpty else { return "–" } - let average = recentSnapshots.map(\.rating.score).reduce(0, +) - / Double(recentSnapshots.count) + let scores = recentSnapshots.map(\.rating.score) + recentEntries.map(\.mood.score) + guard !scores.isEmpty else { return "–" } + let average = scores.reduce(0, +) / Double(scores.count) return SnapshotRating.label(for: average) } } private enum HomeHeaderMessage: String, CaseIterable { - case one = "Ein kleiner Moment reicht, um dich selbst besser zu verstehen." - case two = "Du musst es nicht perfekt sagen. Fang einfach an." + case one = "Ein kleiner Moment reicht, um wieder bei dir anzukommen." + case two = "Du musst es nicht perfekt sagen. Echt reicht." case three = "Heute zählt nicht laut oder leise, sondern ehrlich." case four = "Ein Gedanke nach dem anderen ist genug." - case five = "Deine Notizen sind ein Werkzeug, kein Urteil." + case five = "Deine Notizen begleiten dich, sie bewerten dich nicht." var text: String { rawValue } diff --git a/FeelAloud/MoodAnalysisService.swift b/FeelAloud/MoodAnalysisService.swift index 9598411..341032d 100644 --- a/FeelAloud/MoodAnalysisService.swift +++ b/FeelAloud/MoodAnalysisService.swift @@ -31,6 +31,12 @@ private struct GeneratedDiaryEntry { var stress: Bool } +@Generable +private struct GeneratedOnboardingEssence { + @Guide(description: "Kurze Kernessenz der Nutzeraussage als neutraler deutscher Begriff oder kurze Nominalphrase, ohne Satzanfang, ohne Ich-Form, maximal 4 Wörter") + var essence: String +} + enum MoodAnalysisError: LocalizedError { case emptyTranscript case modelUnavailable @@ -46,6 +52,46 @@ enum MoodAnalysisError: LocalizedError { } struct MoodAnalysisService { + static func extractOnboardingEssence(from text: String) async throws -> String { + let cleanText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanText.isEmpty else { + throw MoodAnalysisError.emptyTranscript + } + + let model = SystemLanguageModel.default + guard model.availability == .available else { + throw MoodAnalysisError.modelUnavailable + } + + let session = LanguageModelSession( + model: model, + instructions: """ + Du extrahierst ausschließlich die Kernessenz aus einer kurzen deutschen Antwort im Onboarding einer privaten Mental-Health-Tagebuch-App. + Formuliere die Essenz als konkreten, wiederverwendungsfertigen Begriff oder sehr kurze Nominalphrase. + Bleibe eng an den genannten Worten und Bedürfnissen der Person. + Gib niemals generische App- oder Therapiebegriffe wie Selbstreflexion, Achtsamkeit, Wohlbefinden, Therapie, Gesundheit oder Stimmung aus, wenn diese nicht ausdrücklich die Kernaussage sind. + Keine Ich-Form, kein ganzer Satz, keine Anführungszeichen, keine medizinische Einschätzung, kein Rat. + Beispiele: + "wenn ich endlich schmerzfrei bin" -> "Schmerzfreiheit" + "ohne rückenschmerzen endlich wieder am leben teilnehmen können" -> "Teilhabe ohne Rückenschmerzen" + "dass ich wieder schlafen kann" -> "erholsamer Schlaf" + "mehr ruhe in meinem kopf" -> "Ruhe im Kopf" + "ich will mich nicht mehr so allein fühlen" -> "Verbundenheit" + """ + ) + + let response = try await session.respond( + to: "Extrahiere die Kernessenz aus dieser Antwort:\\n\\n\\(cleanText)", + generating: GeneratedOnboardingEssence.self + ) + + return normalizedEssence(response.content.essence, fallback: cleanText) + } + + static func fallbackOnboardingEssence(from text: String) -> String { + fallbackEssence(from: text) + } + static func analyze(_ transcript: String) async throws -> DraftMoodEntry { let cleanTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleanTranscript.isEmpty else { @@ -89,4 +135,124 @@ struct MoodAnalysisService { transcript: cleanTranscript ) } + + private static func normalizedEssence(_ essence: String, fallback: String) -> String { + let trimmed = essence + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"“”„")) + + guard !trimmed.isEmpty else { + return fallbackEssence(from: fallback) + } + + let words = trimmed.split(separator: " ").prefix(4).map(String.init) + let compactEssence = words.joined(separator: " ") + guard isPlausibleOnboardingEssence(compactEssence, for: fallback) else { + return fallbackEssence(from: fallback) + } + + return compactEssence + } + + private static func isPlausibleOnboardingEssence(_ essence: String, for source: String) -> Bool { + let normalizedEssence = essence + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let normalizedSource = source.lowercased() + + guard !normalizedEssence.isEmpty else { return false } + + let genericTerms: Set = [ + "selbstreflexion", + "achtsamkeit", + "wohlbefinden", + "therapie", + "gesundheit", + "stimmung", + "emotionale gesundheit", + "mentale gesundheit" + ] + + if genericTerms.contains(normalizedEssence), !normalizedSource.contains(normalizedEssence) { + return false + } + + if normalizedSource.contains("rücken") || normalizedSource.contains("schmerz") { + return normalizedEssence.contains("schmerz") + || normalizedEssence.contains("rücken") + || normalizedEssence.contains("teilhabe") + } + + return true + } + + private static func fallbackEssence(from source: String) -> String { + let normalizedSource = source + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + guard !normalizedSource.isEmpty else { return "" } + + if normalizedSource.contains("rücken") && normalizedSource.contains("schmerz") && normalizedSource.contains("teilnehmen") { + return "Teilhabe ohne Rückenschmerzen" + } + + if normalizedSource.contains("rücken") && normalizedSource.contains("schmerz") { + return "Rückenschmerzfreiheit" + } + + if normalizedSource.contains("schmerzfrei") + || normalizedSource.contains("keine schmerzen") + || normalizedSource.contains("ohne schmerzen") { + return "Schmerzfreiheit" + } + + if normalizedSource.contains("schlafen") { + return "erholsamer Schlaf" + } + + if normalizedSource.contains("allein") || normalizedSource.contains("einsam") { + return "Verbundenheit" + } + + if normalizedSource.contains("ruhe") { + return "Ruhe" + } + + if normalizedSource.contains("kraft") { + return "Kraft" + } + + let separators = CharacterSet(charactersIn: ".,;:!?()[]{}\"“”„") + let cleaned = normalizedSource + .components(separatedBy: separators) + .joined(separator: " ") + + let fillerWords: Set = [ + "ich", "mich", "mir", "mein", "meine", "meinen", "meiner", + "bin", "ist", "sind", "sein", "werde", "werden", "kann", "können", + "habe", "haben", "endlich", "wieder", "einfach", "nur", "dann", + "dass", "wenn", "es", "für", "mit", "und", "oder", "der", "die", "das", + "am", "im", "in", "an", "zu", "zur", "zum", "vom", "von" + ] + + let result = cleaned + .split(separator: " ") + .map(String.init) + .filter { !fillerWords.contains($0) } + .prefix(4) + .joined(separator: " ") + + guard !result.isEmpty else { + return source.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return result + .split(separator: " ") + .map { word in + guard let first = word.first else { return "" } + return first.uppercased() + word.dropFirst() + } + .joined(separator: " ") + } } diff --git a/FeelAloud/MoodEntry.swift b/FeelAloud/MoodEntry.swift index f53bb61..cfd4a09 100644 --- a/FeelAloud/MoodEntry.swift +++ b/FeelAloud/MoodEntry.swift @@ -93,6 +93,7 @@ struct DraftMoodEntry: Sendable { var stress = false var transcript = "" var discussInTherapy = false + var therapistNotes = "" var customCategoryValues: [String: Bool] = [:] } @@ -111,6 +112,7 @@ final class MoodEntry { var stress = false var transcript = "" var discussInTherapy = false + var therapistNotes = "" var customCategoryValuesJSON = "{}" init(draft: DraftMoodEntry, createdAt: Date = .now) { @@ -129,6 +131,9 @@ final class MoodEntry { stress = !draft.musicInHead && draft.stress transcript = draft.transcript discussInTherapy = draft.discussInTherapy + therapistNotes = draft.discussInTherapy + ? draft.therapistNotes.trimmingCharacters(in: .whitespacesAndNewlines) + : "" customCategoryValuesJSON = DiaryCategoryStore.encodeValues(draft.customCategoryValues) } diff --git a/FeelAloud/RootView.swift b/FeelAloud/RootView.swift index 9d03b96..3a22084 100644 --- a/FeelAloud/RootView.swift +++ b/FeelAloud/RootView.swift @@ -1,5 +1,6 @@ import AppIntents import AuthenticationServices +import CryptoKit import PhotosUI import SwiftData import SwiftUI @@ -17,6 +18,8 @@ struct RootView: View { @AppStorage("forceLightAppearance") private var forceLightAppearance = true @AppStorage("lastPresentedFollowUpSnapshotID") private var lastPresentedFollowUpSnapshotID = "" @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false + @AppStorage(UserProfileStore.isLoggedInKey) private var isLoggedIn = false + @AppStorage(UserProfileStore.showOnboardingOnNextLaunchKey) private var showOnboardingOnNextLaunch = false @State private var isUnlocked = false @State private var followUpSnapshot: MoodSnapshot? @@ -29,6 +32,9 @@ struct RootView: View { ZStack { if hasCompletedOnboarding { + if !isLoggedIn && UserProfileStore.hasLocalAccount { + AccountAccessView() + } else { TabView { HomeView() .tabItem { @@ -55,6 +61,7 @@ struct RootView: View { if appLockEnabled && !isUnlocked { AppLockView(isUnlocked: $isUnlocked) } + } } else { OnboardingView() } @@ -81,6 +88,12 @@ struct RootView: View { router.presentNewEntry(startRecording: true) } .task { + if showOnboardingOnNextLaunch { + showOnboardingOnNextLaunch = false + hasCompletedOnboarding = false + return + } + guard hasCompletedOnboarding else { return } isUnlocked = !appLockEnabled @@ -189,9 +202,21 @@ enum UserProfileStore { static let onboardingCompletedKey = "hasCompletedOnboarding" static let displayNameKey = "userDisplayName" static let profileImageDataKey = "userProfileImageData" + static let healthAssociationKey = "healthAssociation" + static let showOnboardingOnNextLaunchKey = "showOnboardingOnNextLaunch" static let accountProviderKey = "accountProvider" static let accountEmailKey = "accountEmail" static let appleUserIDKey = "appleUserID" + static let localAccountNameKey = "localAccountName" + static let localPasswordHashKey = "localPasswordHash" + static let localPasswordSaltKey = "localPasswordSalt" + static let isLoggedInKey = "isLoggedIn" + + static var hasLocalAccount: Bool { + let accountName = UserDefaults.standard.string(forKey: localAccountNameKey) ?? "" + let passwordHash = UserDefaults.standard.string(forKey: localPasswordHashKey) ?? "" + return !accountName.isEmpty && !passwordHash.isEmpty + } static func saveProfile(displayName: String, imageData: Data?) { UserDefaults.standard.set(displayName.trimmingCharacters(in: .whitespacesAndNewlines), forKey: displayNameKey) @@ -216,10 +241,56 @@ enum UserProfileStore { } } + static func saveLocalAccount(accountName: String, email: String, password: String) { + let cleanAccountName = accountName.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let salt = UUID().uuidString + let digest = SHA256.hash(data: Data("\(salt):\(password)".utf8)) + let hash = digest.map { String(format: "%02x", $0) }.joined() + + UserDefaults.standard.set(AccountProvider.local.rawValue, forKey: accountProviderKey) + UserDefaults.standard.set(cleanAccountName, forKey: localAccountNameKey) + UserDefaults.standard.set(cleanEmail, forKey: accountEmailKey) + UserDefaults.standard.set(hash, forKey: localPasswordHashKey) + UserDefaults.standard.set(salt, forKey: localPasswordSaltKey) + UserDefaults.standard.set(true, forKey: isLoggedInKey) + + if !cleanAccountName.isEmpty { + UserDefaults.standard.set(cleanAccountName, forKey: displayNameKey) + } + } + + static func validateLocalLogin(accountNameOrEmail: String, password: String) -> Bool { + let identifier = accountNameOrEmail.trimmingCharacters(in: .whitespacesAndNewlines) + let storedAccountName = UserDefaults.standard.string(forKey: localAccountNameKey) ?? "" + let storedEmail = UserDefaults.standard.string(forKey: accountEmailKey) ?? "" + let storedHash = UserDefaults.standard.string(forKey: localPasswordHashKey) ?? "" + let salt = UserDefaults.standard.string(forKey: localPasswordSaltKey) ?? "" + + guard !identifier.isEmpty, !password.isEmpty, !storedHash.isEmpty, !salt.isEmpty else { + return false + } + + let matchesIdentifier = identifier.localizedCaseInsensitiveCompare(storedAccountName) == .orderedSame + || identifier.localizedCaseInsensitiveCompare(storedEmail) == .orderedSame + guard matchesIdentifier else { return false } + + let digest = SHA256.hash(data: Data("\(salt):\(password)".utf8)) + let hash = digest.map { String(format: "%02x", $0) }.joined() + return hash == storedHash + } + + static func markLoggedOut() { + UserDefaults.standard.set(false, forKey: isLoggedInKey) + } + static func clearAccount() { UserDefaults.standard.removeObject(forKey: accountProviderKey) UserDefaults.standard.removeObject(forKey: accountEmailKey) UserDefaults.standard.removeObject(forKey: appleUserIDKey) + UserDefaults.standard.removeObject(forKey: localAccountNameKey) + UserDefaults.standard.removeObject(forKey: localPasswordHashKey) + UserDefaults.standard.removeObject(forKey: localPasswordSaltKey) } } @@ -239,12 +310,143 @@ enum AccountProvider: String { } } +private struct AccountAccessView: View { + @Environment(\.appColorProfile) private var colorProfile + @AppStorage(UserProfileStore.isLoggedInKey) private var isLoggedIn = false + @AppStorage(UserProfileStore.displayNameKey) private var displayName = "" + @State private var mode: Mode = .login + @State private var accountName = "" + @State private var email = "" + @State private var password = "" + @State private var message: String? + + private enum Mode: String, CaseIterable { + case login = "Anmelden" + case create = "Account erstellen" + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 18) { + FeelAloudLogoMark(size: 82) + .padding(.top, 24) + + VStack(spacing: 8) { + Text("Willkommen zurück") + .font(.title2.weight(.bold)) + Text("Melde dich lokal an oder lege einen neuen lokalen Account an. Deine App-Daten bleiben auf deinem Gerät.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + Picker("Modus", selection: $mode) { + ForEach(Mode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + + VStack(spacing: 12) { + TextField(mode == .login ? "Accountname oder E-Mail" : "Accountname", text: $accountName) + .textContentType(.username) + .textInputAutocapitalization(mode == .login ? .never : .words) + .autocorrectionDisabled(mode == .login) + .submitLabel(.next) + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + + if mode == .create { + TextField("E-Mail", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + } + + SecureField("Passwort", text: $password) + .textContentType(mode == .login ? .password : .newPassword) + .submitLabel(.done) + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + + Button { + submit() + } label: { + Label(mode.rawValue, systemImage: mode == .login ? "person.crop.circle.badge.checkmark" : "person.crop.circle.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + .disabled(!canSubmit) + + if let message { + Text(message) + .font(.footnote) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(16) + .background(.background.opacity(0.78), in: RoundedRectangle(cornerRadius: 20)) + + Text("Apple und Google können später in den Profileinstellungen als optionale Verknüpfung ergänzt werden.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(22) + } + .background(colorProfile.softBackground.opacity(0.26)) + .navigationTitle("Anmelden") + .navigationBarTitleDisplayMode(.inline) + } + .appKeyboardBehavior() + } + + private var canSubmit: Bool { + switch mode { + case .login: + !accountName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !password.isEmpty + case .create: + accountName.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 + && email.trimmingCharacters(in: .whitespacesAndNewlines).contains("@") + && password.count >= 6 + } + } + + private func submit() { + message = nil + + switch mode { + case .login: + guard UserProfileStore.validateLocalLogin(accountNameOrEmail: accountName, password: password) else { + message = "Accountname, E-Mail oder Passwort stimmt nicht." + return + } + isLoggedIn = true + case .create: + UserProfileStore.saveLocalAccount(accountName: accountName, email: email, password: password) + displayName = accountName.trimmingCharacters(in: .whitespacesAndNewlines) + isLoggedIn = true + } + } +} + private struct OnboardingView: View { @Environment(\.appColorProfile) private var colorProfile @Environment(PremiumStore.self) private var premiumStore @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false @AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = "" + @AppStorage(UserProfileStore.healthAssociationKey) private var storedHealthAssociation = "" @AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue + @AppStorage("appLockEnabled") private var appLockEnabled = false @State private var page = 0 @State private var displayName = "" @@ -252,47 +454,79 @@ private struct OnboardingView: View { @State private var profileImageData: Data? @State private var customCategories: [DiaryCategory] = [] @State private var newCategoryName = "" + @State private var onboardingIntention = "" @State private var accountMessage: String? @State private var premiumOfferPulses = false @State private var premiumOfferBursts: [PremiumOfferBurst] = [] @State private var premiumShadowFlares = false + @State private var onboardingIntentionEssence = "" + @State private var isAnalyzingIntention = false + @State private var localAccountName = "" + @State private var localAccountEmail = "" + @State private var localAccountPassword = "" + @FocusState private var isReflectionQuestionFocused: Bool var body: some View { ZStack { - LinearGradient( - colors: [colorProfile.softBackground, Color(.systemBackground)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() + onboardingBackground + .ignoresSafeArea() VStack(spacing: 18) { - progressDots - - TabView(selection: $page) { - welcomePage.tag(0) - diaryPage.tag(1) - snapshotPage.tag(2) - profilePage.tag(3) - categoriesPage.tag(4) - premiumOfferPage.tag(5) - accountPage.tag(6) + if page > 0 { + progressDots + .transition(.opacity) + } else { + Color.clear + .frame(height: 16) + .padding(.top, 8) + } + + if page == 0 { + onboardingStage(reflectionQuestionPage) + .transition(.opacity) + } else { + TabView(selection: $page) { + onboardingStage(welcomePage).tag(1) + onboardingStage(privacyPage).tag(2) + onboardingStage(appProtectionPage).tag(3) + onboardingStage(diaryPage).tag(4) + onboardingStage(snapshotPage).tag(5) + onboardingStage(profilePage).tag(6) + onboardingStage(categoriesPage).tag(7) + onboardingStage(accountPage).tag(8) + onboardingStage(premiumOfferPage).tag(9) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .transition(.opacity) } - .tabViewStyle(.page(indexDisplayMode: .never)) navigationControls } .padding(.horizontal, 22) .padding(.vertical, 18) } + .contentShape(Rectangle()) + .onTapGesture { + isReflectionQuestionFocused = false + } .appKeyboardBehavior() .onAppear { displayName = storedDisplayName + onboardingIntention = storedHealthAssociation + onboardingIntentionEssence = storedHealthAssociation + localAccountName = storedDisplayName customCategories = DiaryCategoryStore.load() } .onChange(of: selectedPhoto) { _, item in Task { await loadSelectedPhoto(item) } } + .onChange(of: page) { _, _ in + isReflectionQuestionFocused = false + } + .onChange(of: onboardingIntention) { _, _ in + guard page == 0 else { return } + onboardingIntentionEssence = "" + } .alert("Account", isPresented: accountMessageBinding) { Button("OK", role: .cancel) { } } message: { @@ -300,11 +534,38 @@ private struct OnboardingView: View { } } + private var onboardingBackground: some View { + ZStack { + LinearGradient( + colors: [ + colorProfile.softBackground, + Color(red: 1.0, green: 0.94, blue: 0.96).opacity(0.70), + Color(.systemBackground) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .opacity(page == 0 ? 0 : 1) + + LinearGradient( + colors: [ + Color(red: 0.46, green: 0.66, blue: 0.55), + Color(red: 0.36, green: 0.57, blue: 0.47), + Color(red: 0.30, green: 0.49, blue: 0.41) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .opacity(page == 0 ? 1 : 0) + } + .animation(.easeInOut(duration: 0.78), value: page) + } + private var progressDots: some View { HStack(spacing: 7) { - ForEach(0..<7, id: \.self) { index in + ForEach(1..<10, id: \.self) { index in Capsule() - .fill(index == page ? colorProfile.accent : Color.secondary.opacity(0.25)) + .fill(progressDotColor(for: index)) .frame(width: index == page ? 26 : 8, height: 8) .animation(.snappy, value: page) } @@ -312,28 +573,189 @@ private struct OnboardingView: View { .padding(.top, 8) } + private func progressDotColor(for index: Int) -> Color { + return index == page ? colorProfile.accent : Color.secondary.opacity(0.25) + } + + private var reflectionQuestionPage: some View { + VStack(spacing: 22) { + Spacer(minLength: 10) + + FeelAloudLogoMark(size: 88, showsBackground: false) + .frame(width: 100, height: 100) + .background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 28)) + .overlay { + RoundedRectangle(cornerRadius: 28) + .stroke(.white.opacity(0.28), lineWidth: 1) + } + + VStack(spacing: 12) { + Text("Wenn Fliegen für Freiheit steht:") + .font(.title3.weight(.semibold)) + .foregroundStyle(.white.opacity(0.82)) + .multilineTextAlignment(.center) + + Text("Wofür steht Gesundheit für dich?") + .font(.largeTitle.bold()) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text("Es gibt keine richtige Antwort. Ein Wort, ein Satz oder ein Bild im Kopf reicht.") + .font(.body) + .foregroundStyle(.white.opacity(0.78)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 10) { + TextField("z.B. Ruhe, Kraft, Nähe, wieder atmen können", text: $onboardingIntention) + .focused($isReflectionQuestionFocused) + .textInputAutocapitalization(.sentences) + .submitLabel(.done) + .onSubmit { + isReflectionQuestionFocused = false + } + .padding(16) + .background(.white, in: RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke(.white.opacity(0.44), lineWidth: 1) + } + + Text("Ein kleiner Schritt für dich, doch ein großer Schritt für dein Wohlbefinden.") + .font(.footnote) + .foregroundStyle(.white.opacity(0.76)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 24)) + .overlay { + RoundedRectangle(cornerRadius: 24) + .stroke(.white.opacity(0.18), lineWidth: 1) + } + + Spacer(minLength: 0) + } + } + + private var trimmedIntention: String { + onboardingIntention.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var intentionEssence: String { + onboardingIntentionEssence.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canContinueOnboarding: Bool { + (page != 0 || trimmedIntention.count >= 3) && !isAnalyzingIntention + } + private var welcomePage: some View { onboardingPage( symbol: "heart.text.square.fill", title: "Willkommen bei Feel Aloud", - text: "Die App hilft dir, Gedanken, Stimmung und wichtige Therapiethemen schnell festzuhalten, ohne dass daraus ein kompliziertes Tagebuch wird.", + text: "Dein sicherer Ort zum Aussprechen, was dir durch den Kopf geht.", rows: [ - OnboardingRow(symbol: "waveform", title: "Sprechen oder schreiben", text: "Halte fest, was gerade da ist."), - OnboardingRow(symbol: "lock.shield", title: "Privat zuerst", text: "Deine Einträge bleiben auf deinem Gerät und optional in deiner iCloud."), - OnboardingRow(symbol: "chart.xyaxis.line", title: "Muster erkennen", text: "Verlauf und Exporte helfen bei Therapiegesprächen.") + OnboardingRow(symbol: "waveform", title: "Sprechen oder schreiben", text: "Halte deine Gedanken und Gefühle fest. Sprich sie aus oder schreibe sie einfach nieder."), + OnboardingRow(symbol: "person.2.wave.2", title: "Therapiebegleitender Assistent", text: "Protokolliere Gefühle, Gedanken und Tagebuchmomente, um deine Psychotherapie gezielt zu unterstützen."), + OnboardingRow(symbol: "text.badge.checkmark", title: "Wichtiges markieren", text: "Markiere Einträge für die nächste Sitzung und ergänze Notizen, die du mit deinem Therapeuten besprechen möchtest.") ] ) } + private var privacyPage: some View { + onboardingPage( + symbol: "lock.shield.fill", + title: "Datenschutz", + text: "Feel Aloud speichert deine Daten lokal auf deinem Gerät. Eine iCloud-Synchronisation ist optional.", + rows: [ + OnboardingRow(symbol: "iphone", title: "Lokal", text: "Es gibt keinen fremden Feel-Aloud-Server, auf dem deine Einträge landen."), + OnboardingRow(symbol: "icloud", title: "iCloud bei Bedarf", text: "Wenn du iCloud später einschaltest, läuft die Synchronisation über deinen Apple-Account."), + OnboardingRow(symbol: "lock.fill", title: "Geräteschutz", text: "iOS schützt App-Daten mit Geräteverschlüsselung; Face ID kann Feel Aloud zusätzlich sperren.") + ] + ) + } + + private var appProtectionPage: some View { + VStack(spacing: 20) { + onboardingHeader( + symbol: "faceid", + title: "App schützen", + text: "Lege direkt fest, ob Feel Aloud beim Öffnen geschützt werden soll. Dafür nutzt die App die lokale Entsperrung deines iPhones." + ) + + VStack(spacing: 12) { + Button { + appLockEnabled = true + } label: { + HStack(spacing: 12) { + Image(systemName: appLockEnabled ? "checkmark.circle.fill" : "lock.shield") + .font(.title3) + .foregroundStyle(appLockEnabled ? .green : colorProfile.accent) + + VStack(alignment: .leading, spacing: 3) { + Text("Face ID oder Gerätecode aktivieren") + .font(.headline) + Text("Beim Öffnen wird Face ID, Touch ID oder der Gerätecode abgefragt.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .layoutPriority(1) + + Spacer(minLength: 0) + } + .padding(14) + .background(colorProfile.softBackground.opacity(0.86), in: RoundedRectangle(cornerRadius: 18)) + } + .buttonStyle(.plain) + .disabled(!AppLockAvailability.canAuthenticate || AppLockAvailability.requiresMissingFaceIDDescription) + + Button { + appLockEnabled = false + } label: { + HStack(spacing: 12) { + Image(systemName: !appLockEnabled ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(!appLockEnabled ? colorProfile.accent : .secondary) + + VStack(alignment: .leading, spacing: 3) { + Text("Später einrichten") + .font(.headline) + Text("Du kannst den Schutz jederzeit in den Einstellungen aktivieren.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .layoutPriority(1) + + Spacer(minLength: 0) + } + .padding(14) + .background(.background.opacity(0.62), in: RoundedRectangle(cornerRadius: 18)) + } + .buttonStyle(.plain) + + if !AppLockAvailability.canAuthenticate || AppLockAvailability.requiresMissingFaceIDDescription { + Text("Auf diesem Gerät ist die lokale Entsperrung noch nicht verfügbar oder nicht vollständig eingerichtet.") + .font(.footnote) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 0) + } + } + private var diaryPage: some View { onboardingPage( symbol: "mic.badge.plus", - title: "Tagebuch ohne Hürde", - text: "Du kannst frei erzählen oder tippen. Feel Aloud sortiert den Eintrag anschließend in verständliche Kategorien ein.", + title: "Tagebuch ohne Hürden", + text: "Sprich frei drauflos oder tippe in Ruhe. Feel Aloud hilft dir danach, das Gesagte einzuordnen.", rows: [ - OnboardingRow(symbol: "brain.head.profile", title: "Reflexion", text: "Gedanken werden zu greifbaren Notizen."), - OnboardingRow(symbol: "tag", title: "Kategorien", text: "Stress, Sozialkontakte und eigene Themen bleiben sichtbar."), - OnboardingRow(symbol: "person.2.wave.2", title: "Therapie", text: "Wichtige Einträge kannst du gezielt markieren.") + OnboardingRow(symbol: "brain.head.profile", title: "Reflexion", text: "Gedanken werden zu Notizen, mit denen du weiterarbeiten kannst."), + OnboardingRow(symbol: "tag", title: "Kategorien", text: "Stimmung, Stress und persönliche Themen bleiben auffindbar."), + OnboardingRow(symbol: "waveform", title: "Sprich es aus", text: "Das Aussprechen deiner Gedanken und Gefühle hilft dir, sie besser zu verarbeiten. Probiere es gerne einmal aus!") ] ) } @@ -343,44 +765,34 @@ private struct OnboardingView: View { onboardingHeader( symbol: "face.smiling", title: "Kurze Momentaufnahme", - text: "Mit fünf einfachen Stimmungen trackst du schnell, wie es dir im Alltag geht, auch über Erinnerungen oder das Widget." + text: "Fünf klare Stufen reichen, um deinen Tag sichtbar zu machen, ohne lange schreiben zu müssen." ) - VStack(spacing: 0) { - ForEach(Array(snapshotScaleItems.enumerated()), id: \.element.id) { index, item in - VStack(spacing: 0) { - HStack(alignment: .top, spacing: 14) { - Image(systemName: item.rating.symbol) - .font(.title2.weight(.semibold)) - .foregroundStyle(item.rating.color) - .frame(width: 44, height: 44) - .background(item.rating.color.opacity(0.12), in: Circle()) + VStack(spacing: 8) { + ForEach(snapshotScaleItems) { item in + HStack(alignment: .top, spacing: 14) { + Image(systemName: item.rating.symbol) + .font(.title2.weight(.semibold)) + .foregroundStyle(item.rating.color) + .frame(width: 46, height: 46) + .background(item.rating.color.opacity(0.12), in: Circle()) - VStack(alignment: .leading, spacing: 3) { - Text(item.rating.rawValue) - .font(.subheadline.weight(.semibold)) - Text(item.description) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - .layoutPriority(1) - - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 3) { + Text(item.rating.rawValue) + .font(.subheadline.weight(.semibold)) + Text(item.description) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - .padding(.vertical, 9) + .layoutPriority(1) - if index < snapshotScaleItems.count - 1 { - Divider() - .padding(.leading, 58) - } + Spacer(minLength: 0) } + .padding(10) + .background(.background.opacity(0.62), in: RoundedRectangle(cornerRadius: 16)) } } - .padding(.horizontal, 14) - .padding(.vertical, 4) - .background(.background.opacity(0.58), in: RoundedRectangle(cornerRadius: 18)) Spacer(minLength: 0) } @@ -405,7 +817,7 @@ private struct OnboardingView: View { onboardingHeader( symbol: "person.crop.circle", title: "Dein Profil", - text: "Diese Angaben helfen, die App persönlicher zu machen. Das Profilbild ist optional." + text: "Gib der App einen Namen oder ein Bild, wenn sie sich persönlicher anfühlen soll." ) VStack(spacing: 16) { @@ -447,7 +859,7 @@ private struct OnboardingView: View { onboardingHeader( symbol: "tag.fill", title: "Deine Themen", - text: "Stimmung, Stress und Sozialkontakte sind voreingestellt. Ergänze hier Themen, die für dich in Therapie oder Alltag wichtig sind." + text: "Starte mit den wichtigsten Bereichen und ergänze nur das, was wirklich zu deinem Alltag passt." ) VStack(alignment: .leading, spacing: 12) { @@ -491,65 +903,156 @@ private struct OnboardingView: View { } private var accountPage: some View { - VStack(spacing: 20) { + ScrollView { + VStack(spacing: 16) { onboardingHeader( symbol: "person.crop.circle.badge.checkmark", title: "Account sichern", - text: "Melde dich an, damit Premium später über den App Store wiederhergestellt werden kann. Google und E-Mail brauchen noch ein Backend, Apple ist in iOS nativ vorgesehen." + text: "Starte lokal auf deinem Gerät. Apple oder Google kannst du später optional verknüpfen." ) - VStack(spacing: 12) { - SignInWithAppleButton(.signUp) { request in - request.requestedScopes = [.fullName, .email] - } onCompletion: { result in - handleAppleSignIn(result) - } - .signInWithAppleButtonStyle(.black) - .frame(height: 48) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "iphone.gen3") + .font(.title3.weight(.semibold)) + .foregroundStyle(colorProfile.accent) + .frame(width: 38, height: 38) + .background(colorProfile.accent.opacity(0.12), in: Circle()) - Button { - accountMessage = "Google Login braucht einen OAuth Client und ein Backend wie Firebase, Supabase oder einen eigenen Server. Sobald du dich für eines davon entscheidest, kann ich die echte Anmeldung anbinden." - } label: { - Label("Mit Google anmelden", systemImage: "g.circle") + VStack(alignment: .leading, spacing: 2) { + Text("Lokaler Login") + .font(.headline) + Text("Deine Daten bleiben lokal. Das Passwort wird nur als Hash gespeichert.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .layoutPriority(1) + } + + TextField("Accountname", text: $localAccountName) + .textContentType(.username) + .textInputAutocapitalization(.words) + .submitLabel(.next) + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + + TextField("E-Mail", text: $localAccountEmail) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + + SecureField("Passwort", text: $localAccountPassword) + .textContentType(.newPassword) + .submitLabel(.done) + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + + Button { + createLocalAccount() + } label: { + Label(localAccountButtonTitle, systemImage: accountProviderRaw == AccountProvider.local.rawValue ? "checkmark.circle.fill" : "person.crop.circle.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + .disabled(!canCreateLocalAccount) + } + .padding(16) + .background(.background.opacity(0.76), in: RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke(colorProfile.accent.opacity(0.10), lineWidth: 1) + } + + VStack(alignment: .leading, spacing: 10) { + Text("Optionale Verknüpfung") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + SignInWithAppleButton(.signUp) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + handleAppleSignIn(result) + } + .signInWithAppleButtonStyle(.black) + .frame(height: 44) .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - Button { - accountMessage = "E-Mail und Passwort darf ich nicht lokal faken. Dafür braucht FeelAloud einen Auth-Server, der Passwörter sicher hasht, Reset-Mails verschickt und Sessions verwaltet." - } label: { - Label("Mit E-Mail registrieren", systemImage: "envelope") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) + Button { + accountMessage = "Google Login braucht einen OAuth Client und ein Backend wie Firebase, Supabase oder einen eigenen Server. Sobald du dich für eines davon entscheidest, kann ich die echte Anmeldung anbinden." + } label: { + VStack(spacing: 5) { + Image(systemName: "g.circle") + .font(.title3.weight(.semibold)) + Text("Google") + .font(.footnote.weight(.semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.bordered) + } - accountRow( - symbol: accountProviderRaw == AccountProvider.apple.rawValue ? "checkmark.seal.fill" : "info.circle", - title: "Aktueller Zugang", - text: AccountProvider(rawValue: accountProviderRaw)?.displayName ?? AccountProvider.local.displayName - ) + Text("Apple und Google sind optional und nur dafür gedacht, Zugang und Wiederherstellung später bequemer zu machen.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .background(.background.opacity(0.62), in: RoundedRectangle(cornerRadius: 18)) + + accountRow( + symbol: accountProviderRaw == AccountProvider.local.rawValue ? "checkmark.seal.fill" : "info.circle", + title: "Aktueller Zugang", + text: AccountProvider(rawValue: accountProviderRaw)?.displayName ?? AccountProvider.local.displayName + ) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.background.opacity(0.62), in: RoundedRectangle(cornerRadius: 18)) + + Text("Das ist keine medizinische Diagnose und ersetzt keine Behandlung. Es ist ein Werkzeug zur persönlichen Dokumentation und Vorbereitung auf Gespräche.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } - .padding(16) - .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) - - Text("Das ist keine medizinische Diagnose und ersetzt keine Behandlung. Es ist ein Werkzeug zur persönlichen Dokumentation und Vorbereitung auf Gespräche.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Spacer(minLength: 0) } + .scrollIndicators(.hidden) } private var premiumOfferPage: some View { - VStack(spacing: 18) { - onboardingHeader( - symbol: "heart.circle.fill", - title: "Unterstütze Feel Aloud", - text: "Ein einmaliger Kauf schaltet Bonusfunktionen frei und hilft, die App werbefrei und unabhängig weiterzuentwickeln." - ) + ScrollView { + VStack(spacing: 12) { + onboardingHeader( + symbol: "heart.circle.fill", + title: "Unterstütze Feel Aloud", + text: "Diese Zahlung ist freiwillig und optional. Du kannst Feel Aloud auch ohne Kauf weiter nutzen.", + markSize: 76, + topPadding: 0 + ) + .padding(.bottom, 2) + + HStack(spacing: 8) { + Image(systemName: "checkmark.shield.fill") + .font(.subheadline.weight(.semibold)) + Text("Freiwillige Unterstützung des Entwicklers") + .font(.footnote.weight(.semibold)) + } + .foregroundStyle(Color(red: 0.55, green: 0.36, blue: 0.43)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(red: 1.0, green: 0.90, blue: 0.93).opacity(0.72), in: Capsule()) + .overlay { + Capsule() + .stroke(Color(red: 0.55, green: 0.36, blue: 0.43).opacity(0.14), lineWidth: 1) + } - VStack(spacing: 16) { Button { triggerPremiumOfferBurst() Task { await purchasePremiumOffer() } @@ -578,12 +1081,12 @@ private struct OnboardingView: View { ProgressView() .tint(colorProfile.accent) } else { - Label("Einmalig freischalten", systemImage: "heart.fill") + Label("Freiwillig unterstützen", systemImage: "heart.fill") .font(.subheadline.weight(.bold)) .foregroundStyle(Color(red: 0.55, green: 0.36, blue: 0.43)) } - Text("Einmalig zahlen. Keine Werbung. Kein Abo.") + Text("Einmalig zahlen. Kein Abo. Die App bleibt auch ohne Kauf nutzbar.") .font(.footnote.weight(.semibold)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -603,7 +1106,8 @@ private struct OnboardingView: View { } .allowsHitTesting(false) .frame(maxWidth: .infinity) - .padding(18) + .padding(.horizontal, 14) + .padding(.vertical, 12) .background( LinearGradient( colors: [ @@ -635,26 +1139,34 @@ private struct OnboardingView: View { lineWidth: 1.4 ) ) - .scaleEffect(premiumOfferPulses ? 1.018 : 0.992) + .overlay { + RoundedRectangle(cornerRadius: 24) + .stroke( + Color(red: 0.55, green: 0.36, blue: 0.43).opacity(premiumOfferPulses ? 0.14 : 0.0), + lineWidth: premiumOfferPulses ? 5 : 1 + ) + .blur(radius: premiumOfferPulses ? 4 : 0) + .scaleEffect(premiumOfferPulses ? 1.14 : 1.0) + .opacity(premiumOfferPulses ? 0.0 : 0.26) + } + .scaleEffect(premiumOfferPulses ? 1.16 : 0.99) .shadow( - color: Color(red: 0.55, green: 0.36, blue: 0.43).opacity(premiumShadowFlares ? 0.42 : premiumOfferPulses ? 0.28 : 0.16), - radius: premiumShadowFlares ? 36 : premiumOfferPulses ? 18 : 10, - y: premiumShadowFlares ? 24 : premiumOfferPulses ? 9 : 5 - ) - .shadow( - color: Color(red: 0.88, green: 0.58, blue: 0.69).opacity(premiumShadowFlares ? 0.28 : 0), - radius: premiumShadowFlares ? 52 : 0, - y: premiumShadowFlares ? 42 : 0 + color: Color(red: 0.55, green: 0.36, blue: 0.43).opacity(premiumShadowFlares ? 0.20 : premiumOfferPulses ? 0.18 : 0.12), + radius: premiumShadowFlares ? 16 : premiumOfferPulses ? 12 : 8, + y: premiumShadowFlares ? 10 : premiumOfferPulses ? 7 : 4 ) + .animation(.easeInOut(duration: 1.52).repeatForever(autoreverses: true), value: premiumOfferPulses) } .buttonStyle(.plain) .disabled(premiumStore.purchaseState == .purchasing) .onAppear { - withAnimation(.easeInOut(duration: 1.15).repeatForever(autoreverses: true)) { + premiumOfferPulses = false + withAnimation(.easeInOut(duration: 1.52).repeatForever(autoreverses: true)) { premiumOfferPulses = true } } - .padding(.horizontal, 4) + .padding(.horizontal, 44) + .padding(.vertical, 28) .zIndex(2) VStack(spacing: 0) { @@ -678,17 +1190,24 @@ private struct OnboardingView: View { } .padding(.horizontal, 14) .padding(.vertical, 4) - .background(.background.opacity(0.58), in: RoundedRectangle(cornerRadius: 18)) + .background(.background.opacity(0.70), in: RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke(colorProfile.accent.opacity(0.08), lineWidth: 1) + } + .shadow(color: Color(red: 0.55, green: 0.36, blue: 0.43).opacity(0.08), radius: 18, y: 8) + .padding(.horizontal, 2) + .padding(.top, 4) .zIndex(0) Text("Der Kauf ist optional. Du kannst Feel Aloud auch ohne Premium weiter nutzen.") .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } - - Spacer(minLength: 0) } + .scrollIndicators(.hidden) } private var accountMessageBinding: Binding { @@ -698,6 +1217,18 @@ private struct OnboardingView: View { ) } + private var canCreateLocalAccount: Bool { + localAccountName.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 + && localAccountEmail.trimmingCharacters(in: .whitespacesAndNewlines).contains("@") + && localAccountPassword.count >= 6 + } + + private var localAccountButtonTitle: String { + accountProviderRaw == AccountProvider.local.rawValue && !localAccountPassword.isEmpty + ? "Lokalen Account aktualisieren" + : "Lokalen Account anlegen" + } + private var navigationControls: some View { HStack(spacing: 12) { if page > 0 { @@ -705,87 +1236,148 @@ private struct OnboardingView: View { withAnimation(.snappy) { page -= 1 } } .buttonStyle(.bordered) + .tint(page == 0 ? .white : colorProfile.accent) } Button { - if page < 6 { + guard canContinueOnboarding else { return } + + if page == 0 { + Task { await analyzeIntentionAndContinue() } + } else if page < 9 { withAnimation(.snappy) { page += 1 } } else { finishOnboarding() } } label: { - Text(page < 6 ? "Weiter" : "Registrierung abschließen") - .frame(maxWidth: .infinity) + HStack(spacing: 8) { + if isAnalyzingIntention { + ProgressView() + .controlSize(.small) + .tint(page == 0 ? colorProfile.accent : .white) + } + + Text(page < 9 ? "Weiter" : "Registrierung abschließen") + } + .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) - .tint(colorProfile.accent) + .tint(page == 0 ? .white : colorProfile.accent) + .foregroundStyle(page == 0 ? colorProfile.accent : .white) + .disabled(!canContinueOnboarding) + .opacity(canContinueOnboarding ? 1 : 0.48) } } + private func onboardingStage(_ content: Content) -> some View { + content + .padding(.horizontal, 2) + .scaleEffect(0.995) + .rotation3DEffect(.degrees(0.001), axis: (x: 0, y: 1, z: 0), perspective: 0.55) + .animation(.smooth(duration: 0.35), value: page) + } + private func onboardingPage(symbol: String, title: String, text: String, rows: [OnboardingRow]) -> some View { VStack(spacing: 20) { onboardingHeader(symbol: symbol, title: title, text: text) - VStack(spacing: 0) { - ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in - VStack(spacing: 0) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: row.symbol) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(colorProfile.accent) - .frame(width: 28, height: 28) - .background(colorProfile.accent.opacity(0.10), in: Circle()) - - VStack(alignment: .leading, spacing: 3) { - Text(row.title) - .font(.subheadline.weight(.semibold)) - Text(row.text) - .font(.footnote) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) - } - .padding(.vertical, 11) - - if index < rows.count - 1 { - Divider() - .padding(.leading, 40) - } - } - } - } - .padding(.horizontal, 14) - .padding(.vertical, 4) - .background(.background.opacity(0.58), in: RoundedRectangle(cornerRadius: 18)) + onboardingRowGroup(rows) Spacer(minLength: 0) } } - private func onboardingHeader(symbol: String, title: String, text: String) -> some View { + private func onboardingRowGroup(_ rows: [OnboardingRow]) -> some View { + VStack(spacing: 10) { + ForEach(rows) { row in + HStack(alignment: .top, spacing: 12) { + Image(systemName: row.symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(colorProfile.accent) + .frame(width: 30, height: 30) + .background(colorProfile.softBackground, in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(row.title) + .font(.subheadline.weight(.semibold)) + Text(row.text) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .layoutPriority(1) + + Spacer(minLength: 0) + } + .padding(12) + .background(rowBackground(for: row), in: RoundedRectangle(cornerRadius: 16)) + .overlay { + if row.symbol == "sparkles" { + RoundedRectangle(cornerRadius: 16) + .stroke(colorProfile.accent.opacity(0.12), lineWidth: 1) + } + } + } + } + } + + private func rowBackground(for row: OnboardingRow) -> Color { + row.symbol == "sparkles" + ? colorProfile.softBackground.opacity(0.95) + : Color(.systemBackground).opacity(0.60) + } + + private func onboardingHeader( + symbol: String, + title: String, + text: String, + markSize: CGFloat = 104, + topPadding: CGFloat = 10 + ) -> some View { VStack(spacing: 14) { - Image(systemName: symbol) - .font(.system(size: 54, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 104, height: 104) - .background( - LinearGradient(colors: colorProfile.gradient, startPoint: .topLeading, endPoint: .bottomTrailing), - in: RoundedRectangle(cornerRadius: 28) - ) - .shadow(color: colorProfile.accent.opacity(0.24), radius: 20, y: 10) + ZStack { + if symbol == "heart.text.square.fill" { + FeelAloudLogoMark(size: markSize) + } else { + Image(systemName: symbol) + .font(.system(size: markSize * 0.48, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: markSize, height: markSize) + .background( + LinearGradient( + colors: [ + colorProfile.accent, + colorProfile.secondary, + Color(red: 0.95, green: 0.68, blue: 0.74).opacity(0.82) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: max(18, markSize * 0.27), style: .continuous) + ) + .overlay(alignment: .topLeading) { + RoundedRectangle(cornerRadius: max(18, markSize * 0.27), style: .continuous) + .stroke(.white.opacity(0.28), lineWidth: 1) + } + .shadow(color: colorProfile.accent.opacity(0.22), radius: 20, y: 10) + } + } VStack(spacing: 8) { Text(title) .font(.title.bold()) .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.82) + .fixedSize(horizontal: false, vertical: true) Text(text) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } } - .padding(.top, 10) + .padding(.top, topPadding) } private func tagFlow(_ tags: [String]) -> some View { @@ -858,10 +1450,46 @@ private struct OnboardingView: View { private func finishOnboarding() { UserProfileStore.saveProfile(displayName: displayName, imageData: profileImageData) + storedHealthAssociation = intentionEssence.isEmpty ? trimmedIntention : intentionEssence DiaryCategoryStore.save(customCategories) hasCompletedOnboarding = true } + private func createLocalAccount() { + guard canCreateLocalAccount else { + accountMessage = "Bitte gib Accountname, gültige E-Mail und ein Passwort mit mindestens 6 Zeichen ein." + return + } + + UserProfileStore.saveLocalAccount( + accountName: localAccountName, + email: localAccountEmail, + password: localAccountPassword + ) + displayName = localAccountName.trimmingCharacters(in: .whitespacesAndNewlines) + storedDisplayName = displayName + accountProviderRaw = AccountProvider.local.rawValue + accountMessage = "Lokaler Account wurde angelegt." + } + + @MainActor + private func analyzeIntentionAndContinue() async { + let rawIntention = trimmedIntention + guard rawIntention.count >= 3, !isAnalyzingIntention else { return } + + isReflectionQuestionFocused = false + isAnalyzingIntention = true + defer { isAnalyzingIntention = false } + + do { + onboardingIntentionEssence = try await MoodAnalysisService.extractOnboardingEssence(from: rawIntention) + } catch { + onboardingIntentionEssence = MoodAnalysisService.fallbackOnboardingEssence(from: rawIntention) + } + + withAnimation(.snappy) { page = 1 } + } + @MainActor private func purchasePremiumOffer() async { if premiumStore.supportProduct == nil { diff --git a/FeelAloud/SettingsView.swift b/FeelAloud/SettingsView.swift index 0874825..666ac5b 100644 --- a/FeelAloud/SettingsView.swift +++ b/FeelAloud/SettingsView.swift @@ -14,6 +14,7 @@ struct SettingsView: View { @Query(sort: \MoodEntry.createdAt) private var entries: [MoodEntry] @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false + @AppStorage(UserProfileStore.isLoggedInKey) private var isLoggedIn = false @AppStorage(UserProfileStore.displayNameKey) private var displayName = "" @AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data() @AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue @@ -27,8 +28,10 @@ struct SettingsView: View { @AppStorage("appLockEnabled") private var appLockEnabled = false @AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue @AppStorage("forceLightAppearance") private var forceLightAppearance = true + @AppStorage("iCloudSyncRequested") private var iCloudSyncRequested = false @AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false @AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = false + @AppStorage(UserProfileStore.showOnboardingOnNextLaunchKey) private var showOnboardingOnNextLaunch = false @State private var notificationStatus = "Wird geprüft …" @State private var notificationMessage: String? @@ -141,6 +144,16 @@ struct SettingsView: View { ) } + NavigationLink { + developmentSettings + } label: { + settingsNavigationRow( + title: "Entwicklung", + subtitle: "Testfunktionen und Debug-Hilfen", + systemImage: "hammer" + ) + } + NavigationLink { appInfoSettings } label: { @@ -182,7 +195,7 @@ struct SettingsView: View { } Button("Abbrechen", role: .cancel) { } } message: { - Text("Deine Tagebucheinträge und Momentaufnahmen bleiben erhalten. Nur Profilangaben und der abgeschlossene Onboarding-Status werden zurückgesetzt.") + Text("Deine Tagebucheinträge, Momentaufnahmen und Accountdaten bleiben erhalten. Du landest danach auf der Login-Seite.") } } @@ -208,33 +221,36 @@ struct SettingsView: View { } } - Section("Anmelden") { - TextField("Backend-URL", text: $backendURL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.done) + Section("Optionale Verknüpfung") { + HStack(spacing: 10) { + SignInWithAppleButton(.signIn) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + handleAppleSignIn(result) + } + .signInWithAppleButtonStyle(.black) + .frame(height: 44) + .frame(maxWidth: .infinity) - SignInWithAppleButton(.signIn) { request in - request.requestedScopes = [.fullName, .email] - } onCompletion: { result in - handleAppleSignIn(result) + Button { + profileMessage = "Google Login kann später mit deiner Google Client ID angebunden werden. Dein lokaler Account funktioniert unabhängig davon." + } label: { + VStack(spacing: 4) { + Image(systemName: "g.circle") + .font(.title3.weight(.semibold)) + Text("Google") + .font(.footnote.weight(.semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.bordered) } - .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") - } + Text("Diese Optionen sind freiwillig. Sie dienen später nur dazu, Zugang und Wiederherstellung komfortabler zu machen.") + .font(.footnote) + .foregroundStyle(.secondary) } Section("Premium") { @@ -370,6 +386,13 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + Section("Speicherort") { + LabeledContent("Standard", value: "Lokal auf diesem Gerät") + Text("Tagebuch, Momentaufnahmen und Therapiedaten werden im App-Speicher dieses Apple-Geräts abgelegt. iOS schützt diesen Speicher über die Geräteverschlüsselung, sobald ein Gerätecode eingerichtet ist.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Section("Lokale KI") { LabeledContent("Apple-Modell") { Label( @@ -392,8 +415,12 @@ struct SettingsView: View { private var syncSettings: some View { List { Section("iCloud Sync") { + Toggle("Eigene iCloud verwenden", isOn: $iCloudSyncRequested) 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.") + Text(iCloudSyncRequested + ? "iCloud-Sync ist vorgemerkt. Die App bleibt lokal, bis iCloud Drive und die CloudKit-Capability für Feel Aloud eingerichtet sind." + : "Solange diese Option aus ist, bleiben deine Daten ausschließlich lokal auf diesem Gerät." + ) .font(.footnote) .foregroundStyle(.secondary) } @@ -460,15 +487,13 @@ struct SettingsView: View { 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.") + Text("Premium wird später über den App Store freigeschaltet. Testschalter liegen jetzt gesammelt unter Einstellungen → Entwicklung.") .font(.footnote) .foregroundStyle(.secondary) @@ -486,6 +511,38 @@ struct SettingsView: View { .navigationBarTitleDisplayMode(.inline) } + private var developmentSettings: some View { + List { + Section("Premium-Test") { + Toggle("Premium features simulieren", isOn: premiumFeaturesToggle) + + Text("Nur für die Testphase: Dieser Schalter simuliert den späteren Unterstützen-Kauf, ohne eine echte App-Store-Transaktion auszulösen.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("Onboarding-Test") { + Button { + showOnboardingOnNextLaunch = true + profileMessage = "Onboarding wird beim nächsten App-Start wieder angezeigt." + } label: { + Label("Onboarding beim nächsten Start anzeigen", systemImage: "arrow.triangle.2.circlepath") + } + + if showOnboardingOnNextLaunch { + Label("Für den nächsten App-Start vorgemerkt", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + + Text("Die aktuellen App-Daten bleiben erhalten. Nur der abgeschlossene Onboarding-Status wird beim nächsten Start zurückgesetzt.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Entwicklung") + .navigationBarTitleDisplayMode(.inline) + } + private var shortcutsSettings: some View { List { Section("Action Button") { @@ -764,12 +821,8 @@ struct SettingsView: View { } private func logoutProfile() { - displayName = "" - profileImageData = Data() - accountEmail = "" - accountProviderRaw = AccountProvider.local.rawValue - UserProfileStore.clearAccount() - hasCompletedOnboarding = false + UserProfileStore.markLoggedOut() + isLoggedIn = false } private func handleAppleSignIn(_ result: Result) { diff --git a/FeelAloud/XLSXExporter.swift b/FeelAloud/XLSXExporter.swift index b2b5da3..b0b8247 100644 --- a/FeelAloud/XLSXExporter.swift +++ b/FeelAloud/XLSXExporter.swift @@ -329,7 +329,9 @@ private struct WorkbookBuilder { .text("Sozialkontakt"), .text("Musik"), .text("Stress"), - .text("Text kurz") + .text("Text kurz"), + .text("Therapie"), + .text("TherapeutenNotizen") ]) rows += entries.map { compactDiaryRow(for: $0) } rows.append([.text(""), .text("")]) @@ -339,13 +341,14 @@ private struct WorkbookBuilder { .text("Ohrwurm"), .text("Leidensdruck"), .text("Gegenmaßnahme"), - .text("Gesprochener Text") + .text("Gesprochener Text"), + .text("TherapeutenNotizen") ]) rows += entries.map { diaryDetailRow(for: $0) } return worksheet( rows: rows, - columnWidths: [20, 16, 10, 18, 16, 42, 14], + columnWidths: [20, 16, 10, 18, 16, 42, 14, 34, 42], headerRow: true, autoFilter: false, drawingRelationshipID: hasChart ? "rId1" : nil, @@ -426,7 +429,9 @@ private struct WorkbookBuilder { .text("Gegenmaßnahmen"), .text("GegenmaßnahmeAktivität"), .text("Stress"), - .text("GesprochenerText") + .text("GesprochenerText"), + .text("InTherapieBesprechen"), + .text("TherapeutenNotizen") ]] rows += entries.map { entry in @@ -435,7 +440,7 @@ private struct WorkbookBuilder { return worksheet( rows: rows, - columnWidths: [14, 11, 18, 10, 16, 28, 18, 17, 17, 19, 32, 12, 55], + columnWidths: [14, 11, 18, 10, 16, 28, 18, 17, 17, 19, 32, 12, 55, 18, 44], headerRow: true, autoFilter: true, drawingRelationshipID: nil, @@ -461,7 +466,9 @@ private struct WorkbookBuilder { .text(entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : ""), .text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""), .text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")), - .text(entry.transcript) + .text(entry.transcript), + .text(entry.discussInTherapy ? "Ja" : "Nein"), + .text(entry.therapistNotes) ] } @@ -473,7 +480,9 @@ private struct WorkbookBuilder { .text(entry.socialContact ? "Ja" : "Nein"), .text(entry.musicInHead ? "Ja" : "Nein"), .text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")), - .text(shortened(entry.transcript, limit: 90)) + .text(shortened(entry.transcript, limit: 90)), + .text(entry.discussInTherapy ? "Ja" : "Nein"), + .text(shortened(entry.therapistNotes, limit: 90)) ] } @@ -484,7 +493,8 @@ private struct WorkbookBuilder { .text(entry.musicInHead ? entry.earwormStrengthRaw : ""), .text(entry.musicInHead ? entry.distressRaw : ""), .text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""), - .text(entry.transcript) + .text(entry.transcript), + .text(entry.therapistNotes) ] } @@ -811,7 +821,7 @@ private enum SimpleZipArchive { archive.appendUInt32LE(centralOffset) archive.appendUInt16LE(0) - try archive.write(to: url, options: .atomic) + try archive.write(to: url, options: [.atomic, .completeFileProtection]) } private static func crc32(_ data: Data) -> UInt32 {