Backup before friendly design pass
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ 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 {
|
||||
@@ -25,6 +34,9 @@ struct HomeView: View {
|
||||
appHeader
|
||||
welcomeCard
|
||||
snapshotButton
|
||||
if showsSafetySupportCard {
|
||||
safetySupportCard
|
||||
}
|
||||
weekOverview
|
||||
|
||||
if let latest = entries.first {
|
||||
@@ -86,6 +98,29 @@ struct HomeView: View {
|
||||
.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(.background, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
private var welcomeCard: some View {
|
||||
Button {
|
||||
router.presentNewEntry(startRecording: false)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -241,6 +241,7 @@ enum AccountProvider: String {
|
||||
|
||||
private struct OnboardingView: View {
|
||||
@Environment(\.appColorProfile) private var colorProfile
|
||||
@Environment(PremiumStore.self) private var premiumStore
|
||||
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
||||
@AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = ""
|
||||
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
|
||||
@@ -252,6 +253,9 @@ private struct OnboardingView: View {
|
||||
@State private var customCategories: [DiaryCategory] = []
|
||||
@State private var newCategoryName = ""
|
||||
@State private var accountMessage: String?
|
||||
@State private var premiumOfferPulses = false
|
||||
@State private var premiumOfferBursts: [PremiumOfferBurst] = []
|
||||
@State private var premiumShadowFlares = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -271,7 +275,8 @@ private struct OnboardingView: View {
|
||||
snapshotPage.tag(2)
|
||||
profilePage.tag(3)
|
||||
categoriesPage.tag(4)
|
||||
accountPage.tag(5)
|
||||
premiumOfferPage.tag(5)
|
||||
accountPage.tag(6)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
@@ -297,7 +302,7 @@ private struct OnboardingView: View {
|
||||
|
||||
private var progressDots: some View {
|
||||
HStack(spacing: 7) {
|
||||
ForEach(0..<6, id: \.self) { index in
|
||||
ForEach(0..<7, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(index == page ? colorProfile.accent : Color.secondary.opacity(0.25))
|
||||
.frame(width: index == page ? 26 : 8, height: 8)
|
||||
@@ -536,6 +541,156 @@ private struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var premiumOfferPage: some View {
|
||||
VStack(spacing: 18) {
|
||||
onboardingHeader(
|
||||
symbol: "heart.circle.fill",
|
||||
title: "Unterstütze Feel Aloud",
|
||||
text: "Ein einmaliger Kauf schaltet Bonusfunktionen frei und hilft, die App werbefrei und unabhängig weiterzuentwickeln."
|
||||
)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Button {
|
||||
triggerPremiumOfferBurst()
|
||||
Task { await purchasePremiumOffer() }
|
||||
} label: {
|
||||
ZStack {
|
||||
VStack(spacing: 8) {
|
||||
Text("Startangebot")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(Color(red: 0.55, green: 0.36, blue: 0.43))
|
||||
.textCase(.uppercase)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text("8,99 €")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.strikethrough(true, color: .secondary)
|
||||
|
||||
Text("4,99 €")
|
||||
.font(.system(size: 46, weight: .bold))
|
||||
.foregroundStyle(Color(red: 0.55, green: 0.36, blue: 0.43))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
|
||||
if premiumStore.purchaseState == .purchasing {
|
||||
ProgressView()
|
||||
.tint(colorProfile.accent)
|
||||
} else {
|
||||
Label("Einmalig freischalten", systemImage: "heart.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(Color(red: 0.55, green: 0.36, blue: 0.43))
|
||||
}
|
||||
|
||||
Text("Einmalig zahlen. Keine Werbung. Kein Abo.")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
ForEach(premiumOfferBursts) { burst in
|
||||
Text(burst.emoji)
|
||||
.font(.system(size: burst.size))
|
||||
.scaleEffect(burst.isFlying ? 1.2 : 0.35)
|
||||
.rotationEffect(.degrees(burst.isFlying ? burst.rotation : 0))
|
||||
.offset(
|
||||
x: burst.isFlying ? burst.endX : burst.startX,
|
||||
y: burst.isFlying ? burst.endY : burst.startY
|
||||
)
|
||||
.opacity(burst.isFlying ? 0 : 1)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(18)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 1.00, green: 0.90, blue: 0.93),
|
||||
Color(red: 0.96, green: 0.76, blue: 0.83),
|
||||
Color(red: 0.88, green: 0.58, blue: 0.69)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 24)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(.white.opacity(0.58), lineWidth: 1)
|
||||
.padding(1.5)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.55, green: 0.36, blue: 0.43).opacity(0.48),
|
||||
Color(red: 0.55, green: 0.36, blue: 0.43).opacity(0.26)
|
||||
],
|
||||
startPoint: .bottomTrailing,
|
||||
endPoint: .topLeading
|
||||
),
|
||||
lineWidth: 1.4
|
||||
)
|
||||
)
|
||||
.scaleEffect(premiumOfferPulses ? 1.018 : 0.992)
|
||||
.shadow(
|
||||
color: Color(red: 0.55, green: 0.36, blue: 0.43).opacity(premiumShadowFlares ? 0.42 : premiumOfferPulses ? 0.28 : 0.16),
|
||||
radius: premiumShadowFlares ? 36 : premiumOfferPulses ? 18 : 10,
|
||||
y: premiumShadowFlares ? 24 : premiumOfferPulses ? 9 : 5
|
||||
)
|
||||
.shadow(
|
||||
color: Color(red: 0.88, green: 0.58, blue: 0.69).opacity(premiumShadowFlares ? 0.28 : 0),
|
||||
radius: premiumShadowFlares ? 52 : 0,
|
||||
y: premiumShadowFlares ? 42 : 0
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(premiumStore.purchaseState == .purchasing)
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 1.15).repeatForever(autoreverses: true)) {
|
||||
premiumOfferPulses = true
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.zIndex(2)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
premiumBenefitRow(
|
||||
symbol: "folder.fill",
|
||||
title: "Projektmappen freischalten",
|
||||
text: "Therapie-Themen, Notizen und Materialien besser sammeln."
|
||||
)
|
||||
Divider().padding(.leading, 42)
|
||||
premiumBenefitRow(
|
||||
symbol: "tablecells.fill",
|
||||
title: "Therapeuten-Export",
|
||||
text: "Strukturierte Auswertung als übersichtlicher Bericht."
|
||||
)
|
||||
Divider().padding(.leading, 42)
|
||||
premiumBenefitRow(
|
||||
symbol: "heart.fill",
|
||||
title: "Entwicklung unterstützen",
|
||||
text: "Hilft, Feel Aloud ruhig, privat und werbefrei zu halten."
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 4)
|
||||
.background(.background.opacity(0.58), in: RoundedRectangle(cornerRadius: 18))
|
||||
.zIndex(0)
|
||||
|
||||
Text("Der Kauf ist optional. Du kannst Feel Aloud auch ohne Premium weiter nutzen.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var accountMessageBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { accountMessage != nil },
|
||||
@@ -553,13 +708,13 @@ private struct OnboardingView: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
if page < 5 {
|
||||
if page < 6 {
|
||||
withAnimation(.snappy) { page += 1 }
|
||||
} else {
|
||||
finishOnboarding()
|
||||
}
|
||||
} label: {
|
||||
Text(page < 5 ? "Weiter" : "Registrierung abschließen")
|
||||
Text(page < 6 ? "Weiter" : "Registrierung abschließen")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -664,6 +819,29 @@ private struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func premiumBenefitRow(symbol: String, title: String, text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: symbol)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(colorProfile.accent)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(colorProfile.accent.opacity(0.10), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(text)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func addCategory() {
|
||||
let name = newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return }
|
||||
@@ -684,6 +862,63 @@ private struct OnboardingView: View {
|
||||
hasCompletedOnboarding = true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func purchasePremiumOffer() async {
|
||||
if premiumStore.supportProduct == nil {
|
||||
premiumStore.enableLocalTestPremium()
|
||||
accountMessage = "Premium wurde testweise freigeschaltet."
|
||||
return
|
||||
}
|
||||
|
||||
await premiumStore.purchaseSupport()
|
||||
|
||||
if premiumStore.premiumFeaturesEnabled {
|
||||
accountMessage = "Premium wurde freigeschaltet."
|
||||
} else if let message = premiumStore.purchaseState.message {
|
||||
accountMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func triggerPremiumOfferBurst() {
|
||||
let emojis = ["🥳", "🎈", "🎉", "🎈", "🥳", "🎊", "🎈", "🎉", "🥳", "🎈", "🎊", "🎉"]
|
||||
premiumOfferBursts = emojis.enumerated().map { index, emoji in
|
||||
let angle = (Double(index) / Double(emojis.count)) * 2 * Double.pi
|
||||
let distance = CGFloat.random(in: 95...165)
|
||||
|
||||
return PremiumOfferBurst(
|
||||
emoji: emoji,
|
||||
startX: CGFloat.random(in: -18...18),
|
||||
startY: CGFloat.random(in: -12...12),
|
||||
endX: cos(angle) * distance + CGFloat.random(in: -26...26),
|
||||
endY: sin(angle) * distance + CGFloat.random(in: -24...18),
|
||||
rotation: Double.random(in: -210...210),
|
||||
size: CGFloat.random(in: 18...25)
|
||||
)
|
||||
}
|
||||
|
||||
premiumShadowFlares = true
|
||||
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(20))
|
||||
withAnimation(.easeOut(duration: 0.9)) {
|
||||
premiumOfferBursts = premiumOfferBursts.map { burst in
|
||||
var updatedBurst = burst
|
||||
updatedBurst.isFlying = true
|
||||
return updatedBurst
|
||||
}
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .milliseconds(180))
|
||||
withAnimation(.easeOut(duration: 0.42)) {
|
||||
premiumShadowFlares = false
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .milliseconds(820))
|
||||
premiumOfferBursts.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAppleSignIn(_ result: Result<ASAuthorization, Error>) {
|
||||
switch result {
|
||||
case .success(let authorization):
|
||||
@@ -781,6 +1016,18 @@ private struct OnboardingSnapshotScaleItem: Identifiable {
|
||||
var id: String { rating.id }
|
||||
}
|
||||
|
||||
private struct PremiumOfferBurst: Identifiable {
|
||||
let id = UUID()
|
||||
let emoji: String
|
||||
let startX: CGFloat
|
||||
let startY: CGFloat
|
||||
let endX: CGFloat
|
||||
let endY: CGFloat
|
||||
let rotation: Double
|
||||
let size: CGFloat
|
||||
var isFlying = false
|
||||
}
|
||||
|
||||
private struct OnboardingProfileImage: View {
|
||||
let imageData: Data?
|
||||
let accent: Color
|
||||
|
||||
@@ -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,106 +48,114 @@ 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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Section("Projektordner") {
|
||||
if premiumFeaturesEnabled {
|
||||
NavigationLink {
|
||||
TherapyProjectListView()
|
||||
} label: {
|
||||
SettingsLikeRow(
|
||||
title: "Projektordner",
|
||||
subtitle: "\(activeProjects.count) Projektordner",
|
||||
systemImage: "folder"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PremiumLockedRow(
|
||||
title: "Projektordner freischalten",
|
||||
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
|
||||
systemImage: "folder.badge.plus"
|
||||
) {
|
||||
showsPremiumPaywall = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Therapiearbeit") {
|
||||
therapyOverview
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
Picker("Filter", selection: $selectedKind) {
|
||||
Text("Alle").tag(nil as TherapyItemKind?)
|
||||
ForEach([TherapyItemKind.goal, .task, .project]) { kind in
|
||||
ForEach([TherapyItemKind.goal, .task]) { kind in
|
||||
Text(kind.rawValue).tag(kind as TherapyItemKind?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
kindInfoRow
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
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.")
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
} 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")
|
||||
}
|
||||
if showsItems {
|
||||
TherapySubsectionHeader(
|
||||
title: "Ziele und Aufgaben",
|
||||
subtitle: "Konkrete Arbeitspunkte aus Alltag und Sitzungen"
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
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",
|
||||
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
|
||||
systemImage: "folder.badge.plus"
|
||||
) {
|
||||
showsPremiumPaywall = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.")
|
||||
TherapyEmptyStateRow(
|
||||
title: "Noch nichts angelegt",
|
||||
subtitle: "Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.",
|
||||
systemImage: "target"
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
.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
|
||||
|
||||
@@ -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