Refine onboarding account and premium flows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1060,21 +1060,17 @@ 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")
|
||||
|
||||
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"
|
||||
Toggle(
|
||||
"In Therapie besprechen",
|
||||
isOn: therapyDiscussionBinding
|
||||
)
|
||||
}
|
||||
|
||||
if entry.discussInTherapy {
|
||||
Section("Zusatznotizen für den Therapeuten") {
|
||||
TextEditor(text: therapistNotesBinding)
|
||||
.frame(minHeight: 100)
|
||||
}
|
||||
}
|
||||
|
||||
if !customCategories.isEmpty {
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -118,7 +167,21 @@ struct HomeView: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(16)
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
||||
.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 }
|
||||
|
||||
|
||||
@@ -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: " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: 46)
|
||||
.frame(height: 44)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
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)
|
||||
}
|
||||
.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>) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user