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 {
|
struct PremiumPaywallView: View {
|
||||||
let title: String
|
let title: String
|
||||||
let message: String
|
let message: String
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ enum CSVExporter {
|
|||||||
"Leidensdruck",
|
"Leidensdruck",
|
||||||
"Gegenmaßnahmen",
|
"Gegenmaßnahmen",
|
||||||
"GegenmaßnahmeAktivität",
|
"GegenmaßnahmeAktivität",
|
||||||
"Stress"
|
"Stress",
|
||||||
|
"InTherapieBesprechen",
|
||||||
|
"TherapeutenNotizen"
|
||||||
].joined(separator: ";")
|
].joined(separator: ";")
|
||||||
|
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
@@ -29,7 +31,9 @@ enum CSVExporter {
|
|||||||
entry.musicInHead ? entry.distressRaw : "",
|
entry.musicInHead ? entry.distressRaw : "",
|
||||||
entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : "",
|
entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : "",
|
||||||
entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : "",
|
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: ";")
|
return values.map(escaped).joined(separator: ";")
|
||||||
}
|
}
|
||||||
@@ -41,7 +45,7 @@ enum CSVExporter {
|
|||||||
let date = filenameFormatter.string(from: .now)
|
let date = filenameFormatter.string(from: .now)
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("FeelAloud-\(date).csv")
|
.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
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ struct EntryFlowView: View {
|
|||||||
Section("Kategorien") {
|
Section("Kategorien") {
|
||||||
Toggle("Stress", isOn: $draft.stress)
|
Toggle("Stress", isOn: $draft.stress)
|
||||||
Toggle("Sozialkontakt", isOn: $draft.socialContact)
|
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
|
ForEach(customCategories) { category in
|
||||||
Toggle(category.name, isOn: customCategoryBinding(for: category))
|
Toggle(category.name, isOn: customCategoryBinding(for: category))
|
||||||
|
|||||||
@@ -1060,21 +1060,17 @@ struct EntryDetailView: View {
|
|||||||
LabeledContent("Stimmung", value: entry.mood.rawValue)
|
LabeledContent("Stimmung", value: entry.mood.rawValue)
|
||||||
LabeledContent("Stress", value: entry.stress ? "Ja" : "Nein")
|
LabeledContent("Stress", value: entry.stress ? "Ja" : "Nein")
|
||||||
LabeledContent("Sozialkontakt", value: entry.socialContact ? "Ja" : "Nein")
|
LabeledContent("Sozialkontakt", value: entry.socialContact ? "Ja" : "Nein")
|
||||||
LabeledContent("Therapie", value: entry.discussInTherapy ? "Markiert" : "Nicht markiert")
|
Toggle(
|
||||||
|
"In Therapie besprechen",
|
||||||
Button {
|
isOn: therapyDiscussionBinding
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !customCategories.isEmpty {
|
if !customCategories.isEmpty {
|
||||||
@@ -1099,8 +1095,7 @@ struct EntryDetailView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
entry.discussInTherapy.toggle()
|
setTherapyDiscussion(!entry.discussInTherapy)
|
||||||
try? modelContext.save()
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: entry.discussInTherapy ? "person.2.wave.2.fill" : "person.2.wave.2")
|
Image(systemName: entry.discussInTherapy ? "person.2.wave.2.fill" : "person.2.wave.2")
|
||||||
}
|
}
|
||||||
@@ -1118,6 +1113,31 @@ struct EntryDetailView: View {
|
|||||||
customCategories = DiaryCategoryStore.load()
|
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 {
|
private struct EditEntryView: View {
|
||||||
@@ -1138,6 +1158,7 @@ private struct EditEntryView: View {
|
|||||||
@State private var stress: Bool
|
@State private var stress: Bool
|
||||||
@State private var transcript: String
|
@State private var transcript: String
|
||||||
@State private var discussInTherapy: Bool
|
@State private var discussInTherapy: Bool
|
||||||
|
@State private var therapistNotes: String
|
||||||
@State private var customCategories: [DiaryCategory]
|
@State private var customCategories: [DiaryCategory]
|
||||||
@State private var customCategoryValues: [String: Bool]
|
@State private var customCategoryValues: [String: Bool]
|
||||||
|
|
||||||
@@ -1155,6 +1176,7 @@ private struct EditEntryView: View {
|
|||||||
_stress = State(initialValue: entry.stress)
|
_stress = State(initialValue: entry.stress)
|
||||||
_transcript = State(initialValue: entry.transcript)
|
_transcript = State(initialValue: entry.transcript)
|
||||||
_discussInTherapy = State(initialValue: entry.discussInTherapy)
|
_discussInTherapy = State(initialValue: entry.discussInTherapy)
|
||||||
|
_therapistNotes = State(initialValue: entry.therapistNotes)
|
||||||
let categories = DiaryCategoryStore.load()
|
let categories = DiaryCategoryStore.load()
|
||||||
_customCategories = State(initialValue: categories)
|
_customCategories = State(initialValue: categories)
|
||||||
_customCategoryValues = State(initialValue: entry.customCategoryValues)
|
_customCategoryValues = State(initialValue: entry.customCategoryValues)
|
||||||
@@ -1175,6 +1197,11 @@ private struct EditEntryView: View {
|
|||||||
Toggle("Sozialkontakt", isOn: $socialContact)
|
Toggle("Sozialkontakt", isOn: $socialContact)
|
||||||
Toggle("Stress", isOn: $stress)
|
Toggle("Stress", isOn: $stress)
|
||||||
Toggle("In Therapie besprechen", isOn: $discussInTherapy)
|
Toggle("In Therapie besprechen", isOn: $discussInTherapy)
|
||||||
|
|
||||||
|
if discussInTherapy {
|
||||||
|
TextField("Zusatznotizen für den Therapeuten", text: $therapistNotes, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !customCategories.isEmpty {
|
if !customCategories.isEmpty {
|
||||||
@@ -1223,6 +1250,9 @@ private struct EditEntryView: View {
|
|||||||
entry.stress = stress
|
entry.stress = stress
|
||||||
entry.transcript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
entry.transcript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
entry.discussInTherapy = discussInTherapy
|
entry.discussInTherapy = discussInTherapy
|
||||||
|
entry.therapistNotes = discussInTherapy
|
||||||
|
? therapistNotes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: ""
|
||||||
entry.customCategoryValuesJSON = DiaryCategoryStore.encodeValues(customCategoryValues)
|
entry.customCategoryValuesJSON = DiaryCategoryStore.encodeValues(customCategoryValues)
|
||||||
|
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ struct HomeView: View {
|
|||||||
@Environment(\.appColorProfile) private var colorProfile
|
@Environment(\.appColorProfile) private var colorProfile
|
||||||
@Query(sort: \MoodEntry.createdAt, order: .reverse) private var entries: [MoodEntry]
|
@Query(sort: \MoodEntry.createdAt, order: .reverse) private var entries: [MoodEntry]
|
||||||
@Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot]
|
@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()
|
@State private var headerMessage = HomeHeaderMessage.random()
|
||||||
|
|
||||||
private var recentEntries: [MoodEntry] {
|
private var recentEntries: [MoodEntry] {
|
||||||
@@ -33,14 +35,20 @@ struct HomeView: View {
|
|||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
appHeader
|
appHeader
|
||||||
welcomeCard
|
welcomeCard
|
||||||
|
if !trimmedHealthAssociation.isEmpty {
|
||||||
|
personalAnchorCard
|
||||||
|
}
|
||||||
snapshotButton
|
snapshotButton
|
||||||
if showsSafetySupportCard {
|
if showsSafetySupportCard {
|
||||||
safetySupportCard
|
safetySupportCard
|
||||||
}
|
}
|
||||||
|
todayCompanionCard
|
||||||
weekOverview
|
weekOverview
|
||||||
|
|
||||||
if let latest = entries.first {
|
if let latest = entries.first {
|
||||||
latestEntry(latest)
|
latestEntry(latest)
|
||||||
|
} else {
|
||||||
|
firstEntryHint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -52,14 +60,10 @@ struct HomeView: View {
|
|||||||
|
|
||||||
private var appHeader: some View {
|
private var appHeader: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "heart.text.square.fill")
|
FeelAloudLogoMark(size: 46)
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.background(colorProfile.accent, in: RoundedRectangle(cornerRadius: 8))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Feel Aloud")
|
Text(greetingTitle)
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
Text(headerMessage.text)
|
Text(headerMessage.text)
|
||||||
.font(.caption)
|
.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 {
|
private var snapshotButton: some View {
|
||||||
Button {
|
Button {
|
||||||
router.presentSnapshot(source: .manual)
|
router.presentSnapshot(source: .manual)
|
||||||
@@ -81,6 +124,8 @@ struct HomeView: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text("🙂")
|
Text("🙂")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
.background(colorProfile.softBackground, in: Circle())
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Momentaufnahme")
|
Text("Momentaufnahme")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -93,7 +138,11 @@ struct HomeView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -118,7 +167,21 @@ struct HomeView: View {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.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 {
|
private var welcomeCard: some View {
|
||||||
@@ -126,18 +189,17 @@ struct HomeView: View {
|
|||||||
router.presentNewEntry(startRecording: false)
|
router.presentNewEntry(startRecording: false)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
Image(systemName: "waveform.and.mic")
|
FeelAloudLogoMark(size: 64, showsBackground: false)
|
||||||
.font(.system(size: 34))
|
.frame(width: 64, height: 64)
|
||||||
.foregroundStyle(.white)
|
.background(.white.opacity(0.20), in: RoundedRectangle(cornerRadius: 18))
|
||||||
.frame(width: 62, height: 62)
|
|
||||||
.background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 18))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
Text("Wie geht es dir gerade?")
|
Text("Wie geht es dir gerade?")
|
||||||
.font(.title3.bold())
|
.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)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.white.opacity(0.86))
|
.foregroundStyle(.white.opacity(0.86))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Image(systemName: "arrow.right.circle.fill")
|
Image(systemName: "arrow.right.circle.fill")
|
||||||
@@ -148,12 +210,17 @@ struct HomeView: View {
|
|||||||
.padding(18)
|
.padding(18)
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: colorProfile.gradient,
|
colors: [
|
||||||
|
colorProfile.accent,
|
||||||
|
colorProfile.secondary,
|
||||||
|
Color(red: 0.93, green: 0.58, blue: 0.67)
|
||||||
|
],
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
),
|
),
|
||||||
in: RoundedRectangle(cornerRadius: 24)
|
in: RoundedRectangle(cornerRadius: 24)
|
||||||
)
|
)
|
||||||
|
.shadow(color: colorProfile.accent.opacity(0.20), radius: 18, y: 10)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityHint("Startet eine lokale Sprachaufnahme")
|
.accessibilityHint("Startet eine lokale Sprachaufnahme")
|
||||||
@@ -186,6 +253,56 @@ struct HomeView: View {
|
|||||||
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
.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 {
|
private func metric(value: String, label: String, symbol: String) -> some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
@@ -233,20 +350,33 @@ struct HomeView: View {
|
|||||||
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
.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 {
|
private var averageSnapshotText: String {
|
||||||
guard !recentSnapshots.isEmpty else { return "–" }
|
let scores = recentSnapshots.map(\.rating.score) + recentEntries.map(\.mood.score)
|
||||||
let average = recentSnapshots.map(\.rating.score).reduce(0, +)
|
guard !scores.isEmpty else { return "–" }
|
||||||
/ Double(recentSnapshots.count)
|
let average = scores.reduce(0, +) / Double(scores.count)
|
||||||
return SnapshotRating.label(for: average)
|
return SnapshotRating.label(for: average)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum HomeHeaderMessage: String, CaseIterable {
|
private enum HomeHeaderMessage: String, CaseIterable {
|
||||||
case one = "Ein kleiner Moment reicht, um dich selbst besser zu verstehen."
|
case one = "Ein kleiner Moment reicht, um wieder bei dir anzukommen."
|
||||||
case two = "Du musst es nicht perfekt sagen. Fang einfach an."
|
case two = "Du musst es nicht perfekt sagen. Echt reicht."
|
||||||
case three = "Heute zählt nicht laut oder leise, sondern ehrlich."
|
case three = "Heute zählt nicht laut oder leise, sondern ehrlich."
|
||||||
case four = "Ein Gedanke nach dem anderen ist genug."
|
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 }
|
var text: String { rawValue }
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ private struct GeneratedDiaryEntry {
|
|||||||
var stress: Bool
|
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 {
|
enum MoodAnalysisError: LocalizedError {
|
||||||
case emptyTranscript
|
case emptyTranscript
|
||||||
case modelUnavailable
|
case modelUnavailable
|
||||||
@@ -46,6 +52,46 @@ enum MoodAnalysisError: LocalizedError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct MoodAnalysisService {
|
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 {
|
static func analyze(_ transcript: String) async throws -> DraftMoodEntry {
|
||||||
let cleanTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
let cleanTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !cleanTranscript.isEmpty else {
|
guard !cleanTranscript.isEmpty else {
|
||||||
@@ -89,4 +135,124 @@ struct MoodAnalysisService {
|
|||||||
transcript: cleanTranscript
|
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 stress = false
|
||||||
var transcript = ""
|
var transcript = ""
|
||||||
var discussInTherapy = false
|
var discussInTherapy = false
|
||||||
|
var therapistNotes = ""
|
||||||
var customCategoryValues: [String: Bool] = [:]
|
var customCategoryValues: [String: Bool] = [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ final class MoodEntry {
|
|||||||
var stress = false
|
var stress = false
|
||||||
var transcript = ""
|
var transcript = ""
|
||||||
var discussInTherapy = false
|
var discussInTherapy = false
|
||||||
|
var therapistNotes = ""
|
||||||
var customCategoryValuesJSON = "{}"
|
var customCategoryValuesJSON = "{}"
|
||||||
|
|
||||||
init(draft: DraftMoodEntry, createdAt: Date = .now) {
|
init(draft: DraftMoodEntry, createdAt: Date = .now) {
|
||||||
@@ -129,6 +131,9 @@ final class MoodEntry {
|
|||||||
stress = !draft.musicInHead && draft.stress
|
stress = !draft.musicInHead && draft.stress
|
||||||
transcript = draft.transcript
|
transcript = draft.transcript
|
||||||
discussInTherapy = draft.discussInTherapy
|
discussInTherapy = draft.discussInTherapy
|
||||||
|
therapistNotes = draft.discussInTherapy
|
||||||
|
? draft.therapistNotes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: ""
|
||||||
customCategoryValuesJSON = DiaryCategoryStore.encodeValues(draft.customCategoryValues)
|
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]
|
@Query(sort: \MoodEntry.createdAt) private var entries: [MoodEntry]
|
||||||
|
|
||||||
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
||||||
|
@AppStorage(UserProfileStore.isLoggedInKey) private var isLoggedIn = false
|
||||||
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
|
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
|
||||||
@AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data()
|
@AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data()
|
||||||
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
|
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
|
||||||
@@ -27,8 +28,10 @@ struct SettingsView: View {
|
|||||||
@AppStorage("appLockEnabled") private var appLockEnabled = false
|
@AppStorage("appLockEnabled") private var appLockEnabled = false
|
||||||
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue
|
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue
|
||||||
@AppStorage("forceLightAppearance") private var forceLightAppearance = true
|
@AppStorage("forceLightAppearance") private var forceLightAppearance = true
|
||||||
|
@AppStorage("iCloudSyncRequested") private var iCloudSyncRequested = false
|
||||||
@AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false
|
@AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false
|
||||||
@AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = 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 notificationStatus = "Wird geprüft …"
|
||||||
@State private var notificationMessage: String?
|
@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 {
|
NavigationLink {
|
||||||
appInfoSettings
|
appInfoSettings
|
||||||
} label: {
|
} label: {
|
||||||
@@ -182,7 +195,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
Button("Abbrechen", role: .cancel) { }
|
Button("Abbrechen", role: .cancel) { }
|
||||||
} message: {
|
} 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") {
|
Section("Optionale Verknüpfung") {
|
||||||
TextField("Backend-URL", text: $backendURL)
|
HStack(spacing: 10) {
|
||||||
.keyboardType(.URL)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.submitLabel(.done)
|
|
||||||
|
|
||||||
SignInWithAppleButton(.signIn) { request in
|
SignInWithAppleButton(.signIn) { request in
|
||||||
request.requestedScopes = [.fullName, .email]
|
request.requestedScopes = [.fullName, .email]
|
||||||
} onCompletion: { result in
|
} onCompletion: { result in
|
||||||
handleAppleSignIn(result)
|
handleAppleSignIn(result)
|
||||||
}
|
}
|
||||||
.signInWithAppleButtonStyle(.black)
|
.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))
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
|
|
||||||
Button {
|
Text("Diese Optionen sind freiwillig. Sie dienen später nur dazu, Zugang und Wiederherstellung komfortabler zu machen.")
|
||||||
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."
|
.font(.footnote)
|
||||||
} label: {
|
.foregroundStyle(.secondary)
|
||||||
Label("Mit Google anmelden", systemImage: "g.circle")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
profileMessage = "Das Backend unterstützt E-Mail/Passwort bereits. Als nächster Schritt braucht die App noch eine eigene Maske für Registrierung, Login und Passwortwechsel gegen die Backend-Endpoints."
|
|
||||||
} label: {
|
|
||||||
Label("Mit E-Mail anmelden", systemImage: "envelope")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Premium") {
|
Section("Premium") {
|
||||||
@@ -370,6 +386,13 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.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") {
|
Section("Lokale KI") {
|
||||||
LabeledContent("Apple-Modell") {
|
LabeledContent("Apple-Modell") {
|
||||||
Label(
|
Label(
|
||||||
@@ -392,8 +415,12 @@ struct SettingsView: View {
|
|||||||
private var syncSettings: some View {
|
private var syncSettings: some View {
|
||||||
List {
|
List {
|
||||||
Section("iCloud Sync") {
|
Section("iCloud Sync") {
|
||||||
|
Toggle("Eigene iCloud verwenden", isOn: $iCloudSyncRequested)
|
||||||
LabeledContent("Account", value: iCloudStatus)
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -460,15 +487,13 @@ struct SettingsView: View {
|
|||||||
private var premiumSettings: some View {
|
private var premiumSettings: some View {
|
||||||
List {
|
List {
|
||||||
Section("Unterstützen") {
|
Section("Unterstützen") {
|
||||||
Toggle("Premium features", isOn: premiumFeaturesToggle)
|
|
||||||
|
|
||||||
Label(
|
Label(
|
||||||
premiumFeaturesEnabled ? "Bonusfunktionen aktiv" : "Bonusfunktionen gesperrt",
|
premiumFeaturesEnabled ? "Bonusfunktionen aktiv" : "Bonusfunktionen gesperrt",
|
||||||
systemImage: premiumFeaturesEnabled ? "checkmark.seal.fill" : "heart"
|
systemImage: premiumFeaturesEnabled ? "checkmark.seal.fill" : "heart"
|
||||||
)
|
)
|
||||||
.foregroundStyle(premiumFeaturesEnabled ? .green : .secondary)
|
.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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
@@ -486,6 +511,38 @@ struct SettingsView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
private var shortcutsSettings: some View {
|
||||||
List {
|
List {
|
||||||
Section("Action Button") {
|
Section("Action Button") {
|
||||||
@@ -764,12 +821,8 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func logoutProfile() {
|
private func logoutProfile() {
|
||||||
displayName = ""
|
UserProfileStore.markLoggedOut()
|
||||||
profileImageData = Data()
|
isLoggedIn = false
|
||||||
accountEmail = ""
|
|
||||||
accountProviderRaw = AccountProvider.local.rawValue
|
|
||||||
UserProfileStore.clearAccount()
|
|
||||||
hasCompletedOnboarding = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleAppleSignIn(_ result: Result<ASAuthorization, Error>) {
|
private func handleAppleSignIn(_ result: Result<ASAuthorization, Error>) {
|
||||||
|
|||||||
@@ -329,7 +329,9 @@ private struct WorkbookBuilder {
|
|||||||
.text("Sozialkontakt"),
|
.text("Sozialkontakt"),
|
||||||
.text("Musik"),
|
.text("Musik"),
|
||||||
.text("Stress"),
|
.text("Stress"),
|
||||||
.text("Text kurz")
|
.text("Text kurz"),
|
||||||
|
.text("Therapie"),
|
||||||
|
.text("TherapeutenNotizen")
|
||||||
])
|
])
|
||||||
rows += entries.map { compactDiaryRow(for: $0) }
|
rows += entries.map { compactDiaryRow(for: $0) }
|
||||||
rows.append([.text(""), .text("")])
|
rows.append([.text(""), .text("")])
|
||||||
@@ -339,13 +341,14 @@ private struct WorkbookBuilder {
|
|||||||
.text("Ohrwurm"),
|
.text("Ohrwurm"),
|
||||||
.text("Leidensdruck"),
|
.text("Leidensdruck"),
|
||||||
.text("Gegenmaßnahme"),
|
.text("Gegenmaßnahme"),
|
||||||
.text("Gesprochener Text")
|
.text("Gesprochener Text"),
|
||||||
|
.text("TherapeutenNotizen")
|
||||||
])
|
])
|
||||||
rows += entries.map { diaryDetailRow(for: $0) }
|
rows += entries.map { diaryDetailRow(for: $0) }
|
||||||
|
|
||||||
return worksheet(
|
return worksheet(
|
||||||
rows: rows,
|
rows: rows,
|
||||||
columnWidths: [20, 16, 10, 18, 16, 42, 14],
|
columnWidths: [20, 16, 10, 18, 16, 42, 14, 34, 42],
|
||||||
headerRow: true,
|
headerRow: true,
|
||||||
autoFilter: false,
|
autoFilter: false,
|
||||||
drawingRelationshipID: hasChart ? "rId1" : nil,
|
drawingRelationshipID: hasChart ? "rId1" : nil,
|
||||||
@@ -426,7 +429,9 @@ private struct WorkbookBuilder {
|
|||||||
.text("Gegenmaßnahmen"),
|
.text("Gegenmaßnahmen"),
|
||||||
.text("GegenmaßnahmeAktivität"),
|
.text("GegenmaßnahmeAktivität"),
|
||||||
.text("Stress"),
|
.text("Stress"),
|
||||||
.text("GesprochenerText")
|
.text("GesprochenerText"),
|
||||||
|
.text("InTherapieBesprechen"),
|
||||||
|
.text("TherapeutenNotizen")
|
||||||
]]
|
]]
|
||||||
|
|
||||||
rows += entries.map { entry in
|
rows += entries.map { entry in
|
||||||
@@ -435,7 +440,7 @@ private struct WorkbookBuilder {
|
|||||||
|
|
||||||
return worksheet(
|
return worksheet(
|
||||||
rows: rows,
|
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,
|
headerRow: true,
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
drawingRelationshipID: nil,
|
drawingRelationshipID: nil,
|
||||||
@@ -461,7 +466,9 @@ private struct WorkbookBuilder {
|
|||||||
.text(entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : ""),
|
.text(entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : ""),
|
||||||
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
||||||
.text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")),
|
.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.socialContact ? "Ja" : "Nein"),
|
||||||
.text(entry.musicInHead ? "Ja" : "Nein"),
|
.text(entry.musicInHead ? "Ja" : "Nein"),
|
||||||
.text(entry.musicInHead ? "" : (entry.stress ? "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.earwormStrengthRaw : ""),
|
||||||
.text(entry.musicInHead ? entry.distressRaw : ""),
|
.text(entry.musicInHead ? entry.distressRaw : ""),
|
||||||
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
.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.appendUInt32LE(centralOffset)
|
||||||
archive.appendUInt16LE(0)
|
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 {
|
private static func crc32(_ data: Data) -> UInt32 {
|
||||||
|
|||||||
Reference in New Issue
Block a user