Compare commits
2 Commits
39a0e35fd2
...
a781a3561f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a781a3561f | ||
|
|
af1e67f4cc |
@@ -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))
|
||||
|
||||
@@ -61,14 +61,14 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Momentaufnahmen") {
|
||||
Section("Allgemeines Stimmungsbild") {
|
||||
summary
|
||||
|
||||
if filteredSnapshots.isEmpty {
|
||||
if dailyMoodPoints.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Momentaufnahmen",
|
||||
"Keine Stimmungsdaten",
|
||||
systemImage: "face.dashed",
|
||||
description: Text("Für diesen Zeitraum wurden noch keine kurzen Checks gespeichert.")
|
||||
description: Text("Für diesen Zeitraum wurden noch keine Momentaufnahmen oder Tagebucheinträge gespeichert.")
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -128,15 +128,6 @@ struct HistoryView: View {
|
||||
)
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
TherapyEntryListView(entries: therapyEntries)
|
||||
} label: {
|
||||
listSummaryRow(
|
||||
title: "In Therapie besprechen",
|
||||
subtitle: "\(therapyEntries.count) markierte Einträge",
|
||||
systemImage: "person.2.wave.2"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verlauf")
|
||||
@@ -185,7 +176,7 @@ struct HistoryView: View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(averageLabel)
|
||||
.font(.title3.bold())
|
||||
Text("Ø aus \(filteredSnapshots.count) Checks")
|
||||
Text("Ø aus \(filteredMoodDataCount) Bewertungen")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(rangeLabel)
|
||||
@@ -197,20 +188,20 @@ struct HistoryView: View {
|
||||
}
|
||||
|
||||
private var snapshotChart: some View {
|
||||
Chart(Array(filteredSnapshots.reversed())) { snapshot in
|
||||
Chart(dailyMoodPoints) { point in
|
||||
LineMark(
|
||||
x: .value("Zeit", snapshot.createdAt),
|
||||
y: .value("Gefühl", snapshot.rating.score)
|
||||
x: .value("Tag", point.date),
|
||||
y: .value("Gefühl", point.averageScore)
|
||||
)
|
||||
.foregroundStyle(colorProfile.accent)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Zeit", snapshot.createdAt),
|
||||
y: .value("Gefühl", snapshot.rating.score)
|
||||
x: .value("Tag", point.date),
|
||||
y: .value("Gefühl", point.averageScore)
|
||||
)
|
||||
.foregroundStyle(snapshot.rating.color)
|
||||
.symbolSize(55)
|
||||
.foregroundStyle(chartColor(for: point.averageScore))
|
||||
.symbolSize(point.totalCount > 1 ? 75 : 55)
|
||||
}
|
||||
.chartYScale(domain: 0.8...5.2)
|
||||
.chartYAxis {
|
||||
@@ -506,8 +497,12 @@ struct HistoryView: View {
|
||||
entries(in: dateRange)
|
||||
}
|
||||
|
||||
private var therapyEntries: [MoodEntry] {
|
||||
entries.filter { $0.discussInTherapy }
|
||||
private var filteredMoodDataCount: Int {
|
||||
filteredSnapshots.count + filteredEntries.count
|
||||
}
|
||||
|
||||
private var dailyMoodPoints: [MoodChartDayPoint] {
|
||||
dailyMoodPoints(snapshots: filteredSnapshots, entries: filteredEntries)
|
||||
}
|
||||
|
||||
private func snapshots(in range: (start: Date, endExclusive: Date, displayEnd: Date)) -> [MoodSnapshot] {
|
||||
@@ -532,6 +527,31 @@ struct HistoryView: View {
|
||||
return entries.map(\.mood.score).reduce(0, +) / Double(entries.count)
|
||||
}
|
||||
|
||||
private func dailyMoodPoints(snapshots: [MoodSnapshot], entries: [MoodEntry]) -> [MoodChartDayPoint] {
|
||||
let calendar = Calendar.current
|
||||
var groupedScores: [Date: [Double]] = [:]
|
||||
|
||||
for snapshot in snapshots {
|
||||
let day = calendar.startOfDay(for: snapshot.createdAt)
|
||||
groupedScores[day, default: []].append(snapshot.rating.score)
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
let day = calendar.startOfDay(for: entry.createdAt)
|
||||
groupedScores[day, default: []].append(entry.mood.score)
|
||||
}
|
||||
|
||||
return groupedScores
|
||||
.map { day, scores in
|
||||
MoodChartDayPoint(
|
||||
date: day,
|
||||
averageScore: scores.reduce(0, +) / Double(scores.count),
|
||||
totalCount: scores.count
|
||||
)
|
||||
}
|
||||
.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
private func weeklyAverageLabel(_ snapshotAverage: Double?, _ entryAverage: Double?) -> String {
|
||||
let values = [snapshotAverage, entryAverage].compactMap { $0 }
|
||||
guard !values.isEmpty else { return "–" }
|
||||
@@ -552,9 +572,9 @@ struct HistoryView: View {
|
||||
}
|
||||
|
||||
private var average: Double? {
|
||||
guard !filteredSnapshots.isEmpty else { return nil }
|
||||
return filteredSnapshots.map(\.rating.score).reduce(0, +)
|
||||
/ Double(filteredSnapshots.count)
|
||||
let scores = filteredSnapshots.map(\.rating.score) + filteredEntries.map(\.mood.score)
|
||||
guard !scores.isEmpty else { return nil }
|
||||
return scores.reduce(0, +) / Double(scores.count)
|
||||
}
|
||||
|
||||
private var averageRating: SnapshotRating? {
|
||||
@@ -581,6 +601,10 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func chartColor(for score: Double) -> Color {
|
||||
SnapshotRating(rawValue: SnapshotRating.label(for: score))?.color ?? colorProfile.accent
|
||||
}
|
||||
|
||||
private var exportErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { exportError != nil },
|
||||
@@ -622,6 +646,14 @@ private struct ExportFile: Identifiable {
|
||||
var id: String { url.absoluteString }
|
||||
}
|
||||
|
||||
private struct MoodChartDayPoint: Identifiable {
|
||||
let date: Date
|
||||
let averageScore: Double
|
||||
let totalCount: Int
|
||||
|
||||
var id: Date { date }
|
||||
}
|
||||
|
||||
private struct SnapshotListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
let snapshots: [MoodSnapshot]
|
||||
@@ -875,7 +907,7 @@ private struct DiaryEntryListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TherapyEntryListView: View {
|
||||
struct TherapyEntryListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
let entries: [MoodEntry]
|
||||
|
||||
@@ -1028,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 {
|
||||
@@ -1067,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")
|
||||
}
|
||||
@@ -1086,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 {
|
||||
@@ -1106,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]
|
||||
|
||||
@@ -1123,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)
|
||||
@@ -1143,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 {
|
||||
@@ -1191,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] {
|
||||
@@ -18,17 +20,35 @@ struct HomeView: View {
|
||||
return snapshots.filter { $0.createdAt >= start }
|
||||
}
|
||||
|
||||
private var recentDifficultScoreCount: Int {
|
||||
recentSnapshots.filter { $0.rating.score <= 2 }.count
|
||||
+ recentEntries.filter { $0.mood.score <= 2 }.count
|
||||
}
|
||||
|
||||
private var showsSafetySupportCard: Bool {
|
||||
recentDifficultScoreCount >= 2
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
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()
|
||||
@@ -40,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)
|
||||
@@ -62,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)
|
||||
@@ -69,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)
|
||||
@@ -81,28 +138,68 @@ 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)
|
||||
}
|
||||
|
||||
private var safetySupportCard: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "cross.case.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(colorProfile.accent)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(colorProfile.accent.opacity(0.1), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Mehrere schwere Momente")
|
||||
.font(.headline)
|
||||
Text("Wenn es gerade belastend ist, halte deinen Notfallplan und unterstützende Kontakte im Therapie-Tab griffbereit.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.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 {
|
||||
Button {
|
||||
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")
|
||||
@@ -113,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")
|
||||
@@ -151,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)
|
||||
@@ -198,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -116,13 +116,15 @@ enum WidgetSnapshotImport {
|
||||
private static let key = "pendingWidgetSnapshots"
|
||||
|
||||
static func consumePendingSnapshots() -> [MoodSnapshot] {
|
||||
guard let defaults = UserDefaults(suiteName: appGroupIdentifier),
|
||||
let data = defaults.data(forKey: key),
|
||||
let pendingSnapshots = try? JSONDecoder().decode([PendingWidgetSnapshot].self, from: data) else {
|
||||
return []
|
||||
let pendingSnapshots = readableDefaults.flatMap { defaults in
|
||||
let snapshots = decodePendingSnapshots(from: defaults)
|
||||
defaults.removeObject(forKey: key)
|
||||
return snapshots
|
||||
}
|
||||
|
||||
defaults.removeObject(forKey: key)
|
||||
guard !pendingSnapshots.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return pendingSnapshots.compactMap { pendingSnapshot in
|
||||
guard let rating = SnapshotRating(rawValue: pendingSnapshot.ratingRawValue) else {
|
||||
@@ -136,6 +138,26 @@ enum WidgetSnapshotImport {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static var readableDefaults: [UserDefaults] {
|
||||
var defaults: [UserDefaults] = [.standard]
|
||||
|
||||
if FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) != nil,
|
||||
let appGroupDefaults = UserDefaults(suiteName: appGroupIdentifier) {
|
||||
defaults.insert(appGroupDefaults, at: 0)
|
||||
}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
private static func decodePendingSnapshots(from defaults: UserDefaults) -> [PendingWidgetSnapshot] {
|
||||
guard let data = defaults.data(forKey: key),
|
||||
let pendingSnapshots = try? JSONDecoder().decode([PendingWidgetSnapshot].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return pendingSnapshots
|
||||
}
|
||||
}
|
||||
|
||||
private struct PendingWidgetSnapshot: Codable {
|
||||
|
||||
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>) {
|
||||
|
||||
@@ -169,13 +169,19 @@ final class SpeechRecognizer: ObservableObject {
|
||||
}
|
||||
|
||||
private func requestMicrophonePermission() async -> Bool {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted:
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
true
|
||||
case .denied:
|
||||
false
|
||||
case .undetermined:
|
||||
await AVAudioApplication.requestRecordPermission()
|
||||
case .restricted:
|
||||
false
|
||||
case .notDetermined:
|
||||
await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .audio) { isAllowed in
|
||||
continuation.resume(returning: isAllowed)
|
||||
}
|
||||
}
|
||||
@unknown default:
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
struct StartDiaryEntryIntent: AppIntent, TargetContentProvidingIntent {
|
||||
static let title: LocalizedStringResource = "Eintrag aufnehmen"
|
||||
@@ -13,6 +15,91 @@ struct StartDiaryEntryIntent: AppIntent, TargetContentProvidingIntent {
|
||||
}
|
||||
}
|
||||
|
||||
enum MomentRatingValue: String, AppEnum, Codable, Hashable, Sendable {
|
||||
case veryGood = "Sehr gut"
|
||||
case good = "Gut"
|
||||
case middle = "Mittel"
|
||||
case bad = "Schlecht"
|
||||
case veryBad = "Sehr schlecht"
|
||||
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Stimmung")
|
||||
|
||||
static let caseDisplayRepresentations: [MomentRatingValue: DisplayRepresentation] = [
|
||||
.veryGood: "Sehr gut",
|
||||
.good: "Gut",
|
||||
.middle: "Mittel",
|
||||
.bad: "Schlecht",
|
||||
.veryBad: "Sehr schlecht"
|
||||
]
|
||||
}
|
||||
|
||||
struct TrackMomentIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Momentaufnahme speichern"
|
||||
static let description = IntentDescription("Speichert eine schnelle Stimmungsabfrage aus dem Homescreen-Widget.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Stimmung")
|
||||
var rating: MomentRatingValue
|
||||
|
||||
init() {
|
||||
rating = .middle
|
||||
}
|
||||
|
||||
init(rating: MomentRatingValue) {
|
||||
self.rating = rating
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
AppMomentWidgetStorage.enqueue(ratingRawValue: rating.rawValue)
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "Moment")
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
enum AppMomentWidgetStorage {
|
||||
static let appGroupIdentifier = "group.de.feelaloud"
|
||||
static let pendingSnapshotsKey = "pendingWidgetSnapshots"
|
||||
static let lastRatingKey = "lastWidgetSnapshotRating"
|
||||
static let lastRecordedAtKey = "lastWidgetSnapshotDate"
|
||||
|
||||
static func enqueue(ratingRawValue: String) {
|
||||
for defaults in writableDefaults {
|
||||
var snapshots = pendingSnapshots(from: defaults)
|
||||
snapshots.append(PendingWidgetSnapshot(ratingRawValue: ratingRawValue, createdAt: .now))
|
||||
|
||||
if let data = try? JSONEncoder().encode(snapshots) {
|
||||
defaults.set(data, forKey: pendingSnapshotsKey)
|
||||
}
|
||||
|
||||
defaults.set(ratingRawValue, forKey: lastRatingKey)
|
||||
defaults.set(Date.now, forKey: lastRecordedAtKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static var writableDefaults: [UserDefaults] {
|
||||
if FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) != nil,
|
||||
let appGroupDefaults = UserDefaults(suiteName: appGroupIdentifier) {
|
||||
return [appGroupDefaults, .standard]
|
||||
}
|
||||
|
||||
return [.standard]
|
||||
}
|
||||
|
||||
private static func pendingSnapshots(from defaults: UserDefaults) -> [PendingWidgetSnapshot] {
|
||||
guard let data = defaults.data(forKey: pendingSnapshotsKey),
|
||||
let snapshots = try? JSONDecoder().decode([PendingWidgetSnapshot].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
}
|
||||
|
||||
private struct PendingWidgetSnapshot: Codable {
|
||||
var ratingRawValue: String
|
||||
var createdAt: Date
|
||||
}
|
||||
|
||||
struct FeelAloudShortcuts: AppShortcutsProvider {
|
||||
static let shortcutTileColor: ShortcutTileColor = .purple
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ struct TherapySupportView: View {
|
||||
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var projectNotes: [TherapyProjectNote]
|
||||
@Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var attachments: [TherapyProjectAttachment]
|
||||
@Query(sort: \EmergencyPlan.updatedAt, order: .reverse) private var plans: [EmergencyPlan]
|
||||
@Query(sort: \MoodEntry.createdAt, order: .reverse) private var diaryEntries: [MoodEntry]
|
||||
@Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot]
|
||||
|
||||
@State private var selectedKind: TherapyItemKind?
|
||||
@State private var editedItem: TherapyItem?
|
||||
@@ -19,6 +21,8 @@ struct TherapySupportView: View {
|
||||
@State private var showsNewProject = false
|
||||
@State private var showsPremiumPaywall = false
|
||||
@State private var infoKind: TherapyItemKind?
|
||||
@State private var showsEmergencyPlan = false
|
||||
@State private var showsSessionMode = false
|
||||
|
||||
private var activeItems: [TherapyItem] {
|
||||
items.filter { item in
|
||||
@@ -32,10 +36,6 @@ struct TherapySupportView: View {
|
||||
projects.filter { !$0.isArchived }
|
||||
}
|
||||
|
||||
private var showsProjects: Bool {
|
||||
selectedKind == nil || selectedKind == .project
|
||||
}
|
||||
|
||||
private var showsItems: Bool {
|
||||
selectedKind == nil || selectedKind == .goal || selectedKind == .task
|
||||
}
|
||||
@@ -48,88 +48,76 @@ struct TherapySupportView: View {
|
||||
items.filter { !$0.isArchived && $0.isCompleted && $0.kind != .project }.count
|
||||
}
|
||||
|
||||
private var therapyEntries: [MoodEntry] {
|
||||
diaryEntries.filter { $0.discussInTherapy }
|
||||
}
|
||||
|
||||
private var plan: EmergencyPlan? {
|
||||
plans.first
|
||||
}
|
||||
|
||||
private var sessionItems: [TherapyItem] {
|
||||
items
|
||||
.filter { !$0.isArchived && !$0.isCompleted && $0.kind != .project }
|
||||
.sorted { first, second in
|
||||
if first.kind == second.kind {
|
||||
return first.createdAt > second.createdAt
|
||||
}
|
||||
return first.kind.rawValue < second.kind.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private var recentSnapshots: [MoodSnapshot] {
|
||||
let start = Calendar.current.date(byAdding: .day, value: -7, to: .now) ?? .distantPast
|
||||
return snapshots.filter { $0.createdAt >= start }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Notfall") {
|
||||
if let plan {
|
||||
NavigationLink {
|
||||
EmergencyPlanView(plan: plan)
|
||||
Section {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
showsEmergencyPlan = true
|
||||
} label: {
|
||||
SettingsLikeRow(
|
||||
TherapyQuickAccessTile(
|
||||
title: "Notfallplan",
|
||||
subtitle: emergencyPlanSubtitle(for: plan),
|
||||
subtitle: plan.map(emergencyPlanSubtitle(for:)) ?? "Wird angelegt",
|
||||
systemImage: "cross.case"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(plan == nil)
|
||||
|
||||
Section("Therapiearbeit") {
|
||||
therapyOverview
|
||||
|
||||
Picker("Filter", selection: $selectedKind) {
|
||||
Text("Alle").tag(nil as TherapyItemKind?)
|
||||
ForEach([TherapyItemKind.goal, .task, .project]) { kind in
|
||||
Text(kind.rawValue).tag(kind as TherapyItemKind?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
kindInfoRow
|
||||
}
|
||||
|
||||
if showsProjects {
|
||||
Section("Projektmappen") {
|
||||
if premiumFeaturesEnabled {
|
||||
if activeProjects.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Noch keine Projektmappe",
|
||||
systemImage: "folder.badge.plus",
|
||||
description: Text("Lege eine Mappe für ein Therapiethema an und sammle darin Notizen, Bilder, Dateien und Ordner.")
|
||||
Button {
|
||||
showsSessionMode = true
|
||||
} label: {
|
||||
TherapyQuickAccessTile(
|
||||
title: "Sitzung",
|
||||
subtitle: "Kompakter Überblick",
|
||||
systemImage: "rectangle.stack.badge.person.crop"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
} else {
|
||||
ForEach(activeProjects) { project in
|
||||
}
|
||||
|
||||
Section("Projektordner") {
|
||||
if premiumFeaturesEnabled {
|
||||
NavigationLink {
|
||||
TherapyProjectDetailView(project: project)
|
||||
TherapyProjectListView()
|
||||
} label: {
|
||||
TherapyProjectRow(
|
||||
project: project,
|
||||
noteCount: projectNotes.filter { $0.projectID == project.id }.count,
|
||||
attachmentCount: attachments.filter { $0.projectID == project.id }.count
|
||||
SettingsLikeRow(
|
||||
title: "Projektordner",
|
||||
subtitle: "\(activeProjects.count) Projektordner",
|
||||
systemImage: "folder"
|
||||
)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
delete(project)
|
||||
} label: {
|
||||
Label("Löschen", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
project.isArchived = true
|
||||
try? modelContext.save()
|
||||
} label: {
|
||||
Label("Archiv", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showsNewProject = true
|
||||
} label: {
|
||||
Label("Projektmappe anlegen", systemImage: "folder.badge.plus")
|
||||
}
|
||||
} else {
|
||||
PremiumLockedRow(
|
||||
title: "Projektmappen freischalten",
|
||||
title: "Projektordner freischalten",
|
||||
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
|
||||
systemImage: "folder.badge.plus"
|
||||
) {
|
||||
@@ -137,17 +125,37 @@ struct TherapySupportView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Therapiearbeit") {
|
||||
therapyOverview
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
Picker("Filter", selection: $selectedKind) {
|
||||
Text("Alle").tag(nil as TherapyItemKind?)
|
||||
ForEach([TherapyItemKind.goal, .task]) { kind in
|
||||
Text(kind.rawValue).tag(kind as TherapyItemKind?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
kindInfoRow
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if showsItems {
|
||||
Section("Ziele und Aufgaben") {
|
||||
if activeItems.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Noch nichts angelegt",
|
||||
systemImage: "target",
|
||||
description: Text("Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.")
|
||||
TherapySubsectionHeader(
|
||||
title: "Ziele und Aufgaben",
|
||||
subtitle: "Konkrete Arbeitspunkte aus Alltag und Sitzungen"
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if activeItems.isEmpty {
|
||||
TherapyEmptyStateRow(
|
||||
title: "Noch nichts angelegt",
|
||||
subtitle: "Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.",
|
||||
systemImage: "target"
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
ForEach(activeItems) { item in
|
||||
Button {
|
||||
@@ -188,8 +196,33 @@ struct TherapySupportView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Besprechen") {
|
||||
NavigationLink {
|
||||
TherapyEntryListView(entries: therapyEntries)
|
||||
} label: {
|
||||
SettingsLikeRow(
|
||||
title: "In Therapie besprechen",
|
||||
subtitle: "\(therapyEntries.count) markierte Tagebucheinträge",
|
||||
systemImage: "person.2.wave.2"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.navigationTitle("Therapie")
|
||||
.navigationDestination(isPresented: $showsEmergencyPlan) {
|
||||
if let plan {
|
||||
EmergencyPlanView(plan: plan)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $showsSessionMode) {
|
||||
TherapySessionView(
|
||||
therapyEntries: therapyEntries,
|
||||
openItems: sessionItems,
|
||||
recentSnapshots: recentSnapshots,
|
||||
plan: plan
|
||||
)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
@@ -235,7 +268,7 @@ struct TherapySupportView: View {
|
||||
}
|
||||
.sheet(isPresented: $showsPremiumPaywall) {
|
||||
PremiumPaywallView(
|
||||
title: "Projektmappen freischalten",
|
||||
title: "Projektordner freischalten",
|
||||
message: "Projektordner sind eine Bonusfunktion für Unterstützer:innen. Ziele, Aufgaben und der Notfallplan bleiben frei nutzbar."
|
||||
)
|
||||
}
|
||||
@@ -247,14 +280,9 @@ struct TherapySupportView: View {
|
||||
|
||||
private var therapyOverview: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
|
||||
spacing: 8
|
||||
) {
|
||||
TherapyMetricView(
|
||||
title: "Mappen",
|
||||
value: premiumFeaturesEnabled ? "\(activeProjects.count)" : "Bonus",
|
||||
systemImage: premiumFeaturesEnabled ? "folder" : "lock"
|
||||
)
|
||||
TherapyMetricView(title: "Offen", value: "\(openItemCount)", systemImage: "circle")
|
||||
TherapyMetricView(title: "Erledigt", value: "\(completedItemCount)", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
@@ -263,14 +291,6 @@ struct TherapySupportView: View {
|
||||
|
||||
private var kindInfoRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
infoKind = .project
|
||||
} label: {
|
||||
Label("Projektmappe", systemImage: "questionmark.circle")
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
ForEach([TherapyItemKind.goal, .task]) { kind in
|
||||
Button {
|
||||
infoKind = kind
|
||||
@@ -359,6 +379,282 @@ private struct PremiumLockedRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TherapyProjectListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \TherapyProject.createdAt, order: .reverse) private var projects: [TherapyProject]
|
||||
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var projectNotes: [TherapyProjectNote]
|
||||
@Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var attachments: [TherapyProjectAttachment]
|
||||
|
||||
@State private var showsNewProject = false
|
||||
|
||||
private var activeProjects: [TherapyProject] {
|
||||
projects.filter { !$0.isArchived }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Projektordner") {
|
||||
if activeProjects.isEmpty {
|
||||
TherapyEmptyStateRow(
|
||||
title: "Noch keine Projektmappe",
|
||||
subtitle: "Lege eine Mappe für ein Therapiethema an und sammle darin Notizen, Bilder, Dateien und Ordner.",
|
||||
systemImage: "folder.badge.plus"
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
ForEach(activeProjects) { project in
|
||||
NavigationLink {
|
||||
TherapyProjectDetailView(project: project)
|
||||
} label: {
|
||||
TherapyProjectRow(
|
||||
project: project,
|
||||
noteCount: projectNotes.filter { $0.projectID == project.id }.count,
|
||||
attachmentCount: attachments.filter { $0.projectID == project.id }.count
|
||||
)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
delete(project)
|
||||
} label: {
|
||||
Label("Löschen", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
project.isArchived = true
|
||||
try? modelContext.save()
|
||||
} label: {
|
||||
Label("Archiv", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showsNewProject = true
|
||||
} label: {
|
||||
Label("Projektmappe anlegen", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Projektordner")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showsNewProject) {
|
||||
EditTherapyProjectView(project: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(_ project: TherapyProject) {
|
||||
let projectAttachments = attachments.filter { $0.projectID == project.id }
|
||||
for attachment in projectAttachments {
|
||||
try? TherapyProjectFileStore.delete(attachment)
|
||||
modelContext.delete(attachment)
|
||||
}
|
||||
projectNotes
|
||||
.filter { $0.projectID == project.id }
|
||||
.forEach(modelContext.delete)
|
||||
modelContext.delete(project)
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
private struct TherapySessionView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.appColorProfile) private var colorProfile
|
||||
|
||||
let therapyEntries: [MoodEntry]
|
||||
let openItems: [TherapyItem]
|
||||
let recentSnapshots: [MoodSnapshot]
|
||||
let plan: EmergencyPlan?
|
||||
|
||||
private var recentAverage: Double? {
|
||||
guard !recentSnapshots.isEmpty else { return nil }
|
||||
return recentSnapshots.map(\.rating.score).reduce(0, +) / Double(recentSnapshots.count)
|
||||
}
|
||||
|
||||
private var recentAverageLabel: String {
|
||||
guard let recentAverage else { return "Noch keine Checks" }
|
||||
return SnapshotRating.label(for: recentAverage)
|
||||
}
|
||||
|
||||
private var recentDifficultCount: Int {
|
||||
recentSnapshots.filter { $0.rating.score <= 2 }.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Überblick") {
|
||||
HStack(spacing: 10) {
|
||||
sessionMetric(
|
||||
value: "\(therapyEntries.count)",
|
||||
label: "Einträge",
|
||||
systemImage: "person.2.wave.2"
|
||||
)
|
||||
sessionMetric(
|
||||
value: "\(openItems.count)",
|
||||
label: "Offen",
|
||||
systemImage: "checklist"
|
||||
)
|
||||
sessionMetric(
|
||||
value: recentAverageLabel,
|
||||
label: "7 Tage",
|
||||
systemImage: "chart.line.uptrend.xyaxis"
|
||||
)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||
}
|
||||
|
||||
Section("In Therapie besprechen") {
|
||||
if therapyEntries.isEmpty {
|
||||
TherapyEmptyStateRow(
|
||||
title: "Keine markierten Einträge",
|
||||
subtitle: "Markiere Tagebucheinträge im Verlauf, damit sie hier für die Sitzung bereitliegen.",
|
||||
systemImage: "person.2.wave.2"
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
ForEach(therapyEntries.prefix(5)) { entry in
|
||||
NavigationLink {
|
||||
EntryDetailView(entry: entry)
|
||||
} label: {
|
||||
SessionEntryRow(entry: entry)
|
||||
}
|
||||
}
|
||||
|
||||
if therapyEntries.count > 5 {
|
||||
NavigationLink {
|
||||
TherapyEntryListView(entries: therapyEntries)
|
||||
} label: {
|
||||
Label("Alle \(therapyEntries.count) markierten Einträge anzeigen", systemImage: "list.bullet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Offene Ziele und Aufgaben") {
|
||||
if openItems.isEmpty {
|
||||
TherapyEmptyStateRow(
|
||||
title: "Keine offenen Punkte",
|
||||
subtitle: "Offene Ziele und Aufgaben erscheinen hier automatisch.",
|
||||
systemImage: "checkmark.circle"
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
ForEach(openItems.prefix(6)) { item in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: item.kind.systemImage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(colorProfile.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(colorProfile.accent.opacity(0.1), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.title.isEmpty ? item.kind.rawValue : item.title)
|
||||
.font(.headline)
|
||||
if !item.notes.isEmpty {
|
||||
Text(item.notes)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
if let dueDate = item.dueDate {
|
||||
Text("Fällig: \(dueDate.formatted(date: .abbreviated, time: .omitted))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
item.isCompleted = true
|
||||
try? modelContext.save()
|
||||
} label: {
|
||||
Label("Erledigt", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Stimmung") {
|
||||
LabeledContent("Durchschnitt letzte 7 Tage", value: recentAverageLabel)
|
||||
LabeledContent("Schwierige Checks", value: "\(recentDifficultCount)")
|
||||
if recentDifficultCount > 0 {
|
||||
Text("Nutze diese Zahl als Gesprächsanlass, nicht als Bewertung.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let plan {
|
||||
Section("Notfallplan") {
|
||||
NavigationLink {
|
||||
EmergencyPlanView(plan: plan)
|
||||
} label: {
|
||||
SettingsLikeRow(
|
||||
title: "Notfallplan öffnen",
|
||||
subtitle: "Akut hilfreich, falls das Gespräch belastend wird",
|
||||
systemImage: "cross.case"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sitzung")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func sessionMetric(value: String, label: String, systemImage: String) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(colorProfile.accent)
|
||||
Text(value)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 74)
|
||||
.padding(.vertical, 8)
|
||||
.background(colorProfile.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionEntryRow: View {
|
||||
let entry: MoodEntry
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: entry.mood.symbol)
|
||||
.font(.title3)
|
||||
.foregroundStyle(entry.mood.color)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(entry.mood.color.opacity(0.12), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(entry.mood.rawValue)
|
||||
.font(.headline)
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if !entry.transcript.isEmpty {
|
||||
Text(entry.transcript)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TherapyProjectDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var allNotes: [TherapyProjectNote]
|
||||
@@ -816,6 +1112,99 @@ private struct SettingsLikeRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TherapyQuickAccessTile: View {
|
||||
@Environment(\.appColorProfile) private var colorProfile
|
||||
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let systemImage: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(colorProfile.accent)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(colorProfile.accent.opacity(0.12), in: Circle())
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary.opacity(0.75))
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 98, alignment: .topLeading)
|
||||
.padding(11)
|
||||
.background(colorProfile.accent.opacity(0.055), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(colorProfile.accent.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TherapySubsectionHeader: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary.opacity(0.82))
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TherapyEmptyStateRow: View {
|
||||
@Environment(\.appColorProfile) private var colorProfile
|
||||
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let systemImage: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(colorProfile.accent.opacity(0.8))
|
||||
.frame(width: 28, height: 28)
|
||||
.background(colorProfile.accent.opacity(0.07), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmergencyPlanView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Bindable var plan: EmergencyPlan
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,7 +60,16 @@ enum MomentWidgetStorage {
|
||||
static let lastRecordedAtKey = "lastWidgetSnapshotDate"
|
||||
|
||||
static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: appGroupIdentifier) ?? .standard
|
||||
guard appGroupAvailable,
|
||||
let defaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return .standard
|
||||
}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
private static var appGroupAvailable: Bool {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) != nil
|
||||
}
|
||||
|
||||
static func enqueue(ratingRawValue: String) {
|
||||
|
||||
Reference in New Issue
Block a user