Refine onboarding account and premium flows

This commit is contained in:
MrDiderot
2026-07-23 15:12:05 +02:00
parent af1e67f4cc
commit a781a3561f
10 changed files with 1323 additions and 251 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Bool> {
Binding(
get: { entry.discussInTherapy },
set: { setTherapyDiscussion($0) }
)
}
private var therapistNotesBinding: Binding<String> {
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()

View File

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

View File

@@ -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<String> = [
"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<String> = [
"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: " ")
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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<ASAuthorization, Error>) {

View File

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