diff --git a/FeelAloud/HistoryView.swift b/FeelAloud/HistoryView.swift index 26ac79f..61bfd73 100644 --- a/FeelAloud/HistoryView.swift +++ b/FeelAloud/HistoryView.swift @@ -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 { 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] diff --git a/FeelAloud/HomeView.swift b/FeelAloud/HomeView.swift index 292fb23..124ef92 100644 --- a/FeelAloud/HomeView.swift +++ b/FeelAloud/HomeView.swift @@ -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) diff --git a/FeelAloud/MoodSnapshot.swift b/FeelAloud/MoodSnapshot.swift index a87c718..3cb5858 100644 --- a/FeelAloud/MoodSnapshot.swift +++ b/FeelAloud/MoodSnapshot.swift @@ -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 { diff --git a/FeelAloud/RootView.swift b/FeelAloud/RootView.swift index 3930c34..9d03b96 100644 --- a/FeelAloud/RootView.swift +++ b/FeelAloud/RootView.swift @@ -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 { 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) { 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 diff --git a/FeelAloud/SpeechRecognizer.swift b/FeelAloud/SpeechRecognizer.swift index 7f893a8..cc358f9 100644 --- a/FeelAloud/SpeechRecognizer.swift +++ b/FeelAloud/SpeechRecognizer.swift @@ -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 } diff --git a/FeelAloud/StartDiaryEntryIntent.swift b/FeelAloud/StartDiaryEntryIntent.swift index aec3fe3..3b12356 100644 --- a/FeelAloud/StartDiaryEntryIntent.swift +++ b/FeelAloud/StartDiaryEntryIntent.swift @@ -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 diff --git a/FeelAloud/TherapySupportView.swift b/FeelAloud/TherapySupportView.swift index c704658..3ae2836 100644 --- a/FeelAloud/TherapySupportView.swift +++ b/FeelAloud/TherapySupportView.swift @@ -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 diff --git a/Moment/AppIntent.swift b/Moment/AppIntent.swift index 96e1b4a..9fd4563 100644 --- a/Moment/AppIntent.swift +++ b/Moment/AppIntent.swift @@ -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) {