Compare commits
4 Commits
10fca0756d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a781a3561f | ||
|
|
af1e67f4cc | ||
|
|
39a0e35fd2 | ||
|
|
77d93c4f8d |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -20,6 +20,10 @@ DerivedData/
|
|||||||
.swiftpm/
|
.swiftpm/
|
||||||
Package.resolved
|
Package.resolved
|
||||||
|
|
||||||
|
# Node backend
|
||||||
|
node_modules/
|
||||||
|
backend/dist/
|
||||||
|
|
||||||
# Local configuration and secrets
|
# Local configuration and secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
@@ -30,4 +34,3 @@ Package.resolved
|
|||||||
# Temporary exports
|
# Temporary exports
|
||||||
exports/
|
exports/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
E296A44130110492003F93AA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44030110491003F93AA /* WidgetKit.framework */; };
|
E296A44130110492003F93AA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44030110491003F93AA /* WidgetKit.framework */; };
|
||||||
E296A44330110492003F93AA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44230110492003F93AA /* SwiftUI.framework */; };
|
E296A44330110492003F93AA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44230110492003F93AA /* SwiftUI.framework */; };
|
||||||
E296A45430110492003F93AA /* MomentExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E296A43E30110491003F93AA /* MomentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
E296A45430110492003F93AA /* MomentExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E296A43E30110491003F93AA /* MomentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
E296A466301164BE003F93AA /* AccountBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = E296A465301164BE003F93AA /* AccountBackend.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -86,6 +87,7 @@
|
|||||||
E296A44030110491003F93AA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
E296A44030110491003F93AA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
E296A44230110492003F93AA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
E296A44230110492003F93AA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
E296A45E30110993003F93AA /* MomentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MomentExtension.entitlements; sourceTree = "<group>"; };
|
E296A45E30110993003F93AA /* MomentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MomentExtension.entitlements; sourceTree = "<group>"; };
|
||||||
|
E296A465301164BE003F93AA /* AccountBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBackend.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -158,6 +160,7 @@
|
|||||||
E296A4343010C110003F93AA /* TherapySupportView.swift */,
|
E296A4343010C110003F93AA /* TherapySupportView.swift */,
|
||||||
E296A4363010C14D003F93AA /* AppLockView.swift */,
|
E296A4363010C14D003F93AA /* AppLockView.swift */,
|
||||||
E296A4383010FDD6003F93AA /* AppTheme.swift */,
|
E296A4383010FDD6003F93AA /* AppTheme.swift */,
|
||||||
|
E296A465301164BE003F93AA /* AccountBackend.swift */,
|
||||||
);
|
);
|
||||||
path = FeelAloud;
|
path = FeelAloud;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -303,6 +306,7 @@
|
|||||||
A10000000000000000000008 /* HomeView.swift in Sources */,
|
A10000000000000000000008 /* HomeView.swift in Sources */,
|
||||||
E296A4393010FDD6003F93AA /* AppTheme.swift in Sources */,
|
E296A4393010FDD6003F93AA /* AppTheme.swift in Sources */,
|
||||||
A10000000000000000000009 /* EntryFlowView.swift in Sources */,
|
A10000000000000000000009 /* EntryFlowView.swift in Sources */,
|
||||||
|
E296A466301164BE003F93AA /* AccountBackend.swift in Sources */,
|
||||||
A1000000000000000000000A /* HistoryView.swift in Sources */,
|
A1000000000000000000000A /* HistoryView.swift in Sources */,
|
||||||
A1000000000000000000000B /* SettingsView.swift in Sources */,
|
A1000000000000000000000B /* SettingsView.swift in Sources */,
|
||||||
A1000000000000000000000D /* MoodSnapshot.swift in Sources */,
|
A1000000000000000000000D /* MoodSnapshot.swift in Sources */,
|
||||||
|
|||||||
111
FeelAloud/AccountBackend.swift
Normal file
111
FeelAloud/AccountBackend.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AccountBackendStore {
|
||||||
|
static let backendURLKey = "authBackendURL"
|
||||||
|
static let sessionTokenKey = "authSessionToken"
|
||||||
|
static let remoteUserIDKey = "authRemoteUserID"
|
||||||
|
|
||||||
|
static var backendURL: URL? {
|
||||||
|
guard let value = UserDefaults.standard.string(forKey: backendURLKey),
|
||||||
|
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return URL(string: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(session: AccountSession) {
|
||||||
|
UserDefaults.standard.set(session.token, forKey: sessionTokenKey)
|
||||||
|
UserDefaults.standard.set(session.user.id, forKey: remoteUserIDKey)
|
||||||
|
UserDefaults.standard.set(session.user.email ?? "", forKey: UserProfileStore.accountEmailKey)
|
||||||
|
UserDefaults.standard.set(session.user.displayName, forKey: UserProfileStore.displayNameKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clearSession() {
|
||||||
|
UserDefaults.standard.removeObject(forKey: sessionTokenKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: remoteUserIDKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountBackendClient {
|
||||||
|
enum BackendError: LocalizedError {
|
||||||
|
case missingBackendURL
|
||||||
|
case invalidURL
|
||||||
|
case invalidResponse
|
||||||
|
case server(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingBackendURL:
|
||||||
|
"Backend-URL ist noch nicht konfiguriert."
|
||||||
|
case .invalidURL:
|
||||||
|
"Backend-URL ist ungültig."
|
||||||
|
case .invalidResponse:
|
||||||
|
"Backend hat keine gültige Antwort geliefert."
|
||||||
|
case .server(let message):
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signInWithApple(identityToken: String, authorizationCode: String?, email: String?, displayName: String?) async throws -> AccountSession {
|
||||||
|
try await post(
|
||||||
|
path: "/auth/apple",
|
||||||
|
body: AppleSignInRequest(
|
||||||
|
identityToken: identityToken,
|
||||||
|
authorizationCode: authorizationCode,
|
||||||
|
email: email,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post<RequestBody: Encodable, ResponseBody: Decodable>(path: String, body: RequestBody) async throws -> ResponseBody {
|
||||||
|
guard let baseURL = AccountBackendStore.backendURL else {
|
||||||
|
throw BackendError.missingBackendURL
|
||||||
|
}
|
||||||
|
guard let url = URL(string: path, relativeTo: baseURL) else {
|
||||||
|
throw BackendError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw BackendError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||||
|
let error = try? JSONDecoder().decode(BackendErrorResponse.self, from: data)
|
||||||
|
throw BackendError.server(error?.message ?? error?.error ?? "Backend-Fehler: HTTP \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return try JSONDecoder().decode(ResponseBody.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AppleSignInRequest: Encodable {
|
||||||
|
let identityToken: String
|
||||||
|
let authorizationCode: String?
|
||||||
|
let email: String?
|
||||||
|
let displayName: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountSession: Decodable {
|
||||||
|
let token: String
|
||||||
|
let user: AccountUser
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountUser: Decodable {
|
||||||
|
let id: String
|
||||||
|
let email: String?
|
||||||
|
let displayName: String
|
||||||
|
let avatarURL: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BackendErrorResponse: Decodable {
|
||||||
|
let error: String?
|
||||||
|
let message: String?
|
||||||
|
}
|
||||||
@@ -59,12 +59,12 @@ enum AppColorProfile: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func stored(_ rawValue: String) -> AppColorProfile {
|
static func stored(_ rawValue: String) -> AppColorProfile {
|
||||||
AppColorProfile(rawValue: rawValue) ?? .lavender
|
AppColorProfile(rawValue: rawValue) ?? .sage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct AppColorProfileKey: EnvironmentKey {
|
private struct AppColorProfileKey: EnvironmentKey {
|
||||||
static let defaultValue: AppColorProfile = .lavender
|
static let defaultValue: AppColorProfile = .sage
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EnvironmentValues {
|
extension EnvironmentValues {
|
||||||
@@ -247,6 +247,46 @@ enum PremiumPurchaseState: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FeelAloudLogoMark: View {
|
||||||
|
@Environment(\.appColorProfile) private var colorProfile
|
||||||
|
|
||||||
|
var size: CGFloat = 52
|
||||||
|
var showsBackground: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if showsBackground {
|
||||||
|
RoundedRectangle(cornerRadius: max(8, size * 0.23), style: .continuous)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
colorProfile.accent.opacity(0.92),
|
||||||
|
colorProfile.secondary.opacity(0.76),
|
||||||
|
Color(red: 0.95, green: 0.68, blue: 0.74).opacity(0.74)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "heart.fill")
|
||||||
|
.font(.system(size: size * 0.58, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.shadow(color: .black.opacity(0.10), radius: 3, y: 2)
|
||||||
|
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.system(size: size * 0.25, weight: .bold))
|
||||||
|
.foregroundStyle(colorProfile.accent)
|
||||||
|
.offset(y: size * 0.01)
|
||||||
|
.blendMode(.plusDarker)
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.shadow(color: colorProfile.accent.opacity(showsBackground ? 0.20 : 0), radius: size * 0.22, y: size * 0.10)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct PremiumPaywallView: View {
|
struct PremiumPaywallView: View {
|
||||||
let title: String
|
let title: String
|
||||||
let message: String
|
let message: String
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ enum CSVExporter {
|
|||||||
"Leidensdruck",
|
"Leidensdruck",
|
||||||
"Gegenmaßnahmen",
|
"Gegenmaßnahmen",
|
||||||
"GegenmaßnahmeAktivität",
|
"GegenmaßnahmeAktivität",
|
||||||
"Stress"
|
"Stress",
|
||||||
|
"InTherapieBesprechen",
|
||||||
|
"TherapeutenNotizen"
|
||||||
].joined(separator: ";")
|
].joined(separator: ";")
|
||||||
|
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
@@ -29,7 +31,9 @@ enum CSVExporter {
|
|||||||
entry.musicInHead ? entry.distressRaw : "",
|
entry.musicInHead ? entry.distressRaw : "",
|
||||||
entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : "",
|
entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : "",
|
||||||
entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : "",
|
entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : "",
|
||||||
entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")
|
entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein"),
|
||||||
|
entry.discussInTherapy ? "Ja" : "Nein",
|
||||||
|
entry.therapistNotes
|
||||||
]
|
]
|
||||||
return values.map(escaped).joined(separator: ";")
|
return values.map(escaped).joined(separator: ";")
|
||||||
}
|
}
|
||||||
@@ -41,7 +45,7 @@ enum CSVExporter {
|
|||||||
let date = filenameFormatter.string(from: .now)
|
let date = filenameFormatter.string(from: .now)
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("FeelAloud-\(date).csv")
|
.appendingPathComponent("FeelAloud-\(date).csv")
|
||||||
try content.write(to: url, atomically: true, encoding: .utf8)
|
try Data(content.utf8).write(to: url, options: [.atomic, .completeFileProtection])
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ struct EntryFlowView: View {
|
|||||||
Section("Kategorien") {
|
Section("Kategorien") {
|
||||||
Toggle("Stress", isOn: $draft.stress)
|
Toggle("Stress", isOn: $draft.stress)
|
||||||
Toggle("Sozialkontakt", isOn: $draft.socialContact)
|
Toggle("Sozialkontakt", isOn: $draft.socialContact)
|
||||||
|
Toggle("In Therapie besprechen", isOn: $draft.discussInTherapy)
|
||||||
|
|
||||||
|
if draft.discussInTherapy {
|
||||||
|
TextField("Zusatznotizen für den Therapeuten", text: $draft.therapistNotes, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(customCategories) { category in
|
ForEach(customCategories) { category in
|
||||||
Toggle(category.name, isOn: customCategoryBinding(for: category))
|
Toggle(category.name, isOn: customCategoryBinding(for: category))
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Momentaufnahmen") {
|
Section("Allgemeines Stimmungsbild") {
|
||||||
summary
|
summary
|
||||||
|
|
||||||
if filteredSnapshots.isEmpty {
|
if dailyMoodPoints.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Keine Momentaufnahmen",
|
"Keine Stimmungsdaten",
|
||||||
systemImage: "face.dashed",
|
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)
|
.frame(maxWidth: .infinity)
|
||||||
.listRowBackground(Color.clear)
|
.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")
|
.navigationTitle("Verlauf")
|
||||||
@@ -185,7 +176,7 @@ struct HistoryView: View {
|
|||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(averageLabel)
|
Text(averageLabel)
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
Text("Ø aus \(filteredSnapshots.count) Checks")
|
Text("Ø aus \(filteredMoodDataCount) Bewertungen")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text(rangeLabel)
|
Text(rangeLabel)
|
||||||
@@ -197,20 +188,20 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var snapshotChart: some View {
|
private var snapshotChart: some View {
|
||||||
Chart(Array(filteredSnapshots.reversed())) { snapshot in
|
Chart(dailyMoodPoints) { point in
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Zeit", snapshot.createdAt),
|
x: .value("Tag", point.date),
|
||||||
y: .value("Gefühl", snapshot.rating.score)
|
y: .value("Gefühl", point.averageScore)
|
||||||
)
|
)
|
||||||
.foregroundStyle(colorProfile.accent)
|
.foregroundStyle(colorProfile.accent)
|
||||||
.interpolationMethod(.catmullRom)
|
.interpolationMethod(.catmullRom)
|
||||||
|
|
||||||
PointMark(
|
PointMark(
|
||||||
x: .value("Zeit", snapshot.createdAt),
|
x: .value("Tag", point.date),
|
||||||
y: .value("Gefühl", snapshot.rating.score)
|
y: .value("Gefühl", point.averageScore)
|
||||||
)
|
)
|
||||||
.foregroundStyle(snapshot.rating.color)
|
.foregroundStyle(chartColor(for: point.averageScore))
|
||||||
.symbolSize(55)
|
.symbolSize(point.totalCount > 1 ? 75 : 55)
|
||||||
}
|
}
|
||||||
.chartYScale(domain: 0.8...5.2)
|
.chartYScale(domain: 0.8...5.2)
|
||||||
.chartYAxis {
|
.chartYAxis {
|
||||||
@@ -506,8 +497,12 @@ struct HistoryView: View {
|
|||||||
entries(in: dateRange)
|
entries(in: dateRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var therapyEntries: [MoodEntry] {
|
private var filteredMoodDataCount: Int {
|
||||||
entries.filter { $0.discussInTherapy }
|
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] {
|
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)
|
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 {
|
private func weeklyAverageLabel(_ snapshotAverage: Double?, _ entryAverage: Double?) -> String {
|
||||||
let values = [snapshotAverage, entryAverage].compactMap { $0 }
|
let values = [snapshotAverage, entryAverage].compactMap { $0 }
|
||||||
guard !values.isEmpty else { return "–" }
|
guard !values.isEmpty else { return "–" }
|
||||||
@@ -552,9 +572,9 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var average: Double? {
|
private var average: Double? {
|
||||||
guard !filteredSnapshots.isEmpty else { return nil }
|
let scores = filteredSnapshots.map(\.rating.score) + filteredEntries.map(\.mood.score)
|
||||||
return filteredSnapshots.map(\.rating.score).reduce(0, +)
|
guard !scores.isEmpty else { return nil }
|
||||||
/ Double(filteredSnapshots.count)
|
return scores.reduce(0, +) / Double(scores.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var averageRating: SnapshotRating? {
|
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> {
|
private var exportErrorBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { exportError != nil },
|
get: { exportError != nil },
|
||||||
@@ -622,6 +646,14 @@ private struct ExportFile: Identifiable {
|
|||||||
var id: String { url.absoluteString }
|
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 {
|
private struct SnapshotListView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
let snapshots: [MoodSnapshot]
|
let snapshots: [MoodSnapshot]
|
||||||
@@ -875,7 +907,7 @@ private struct DiaryEntryListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct TherapyEntryListView: View {
|
struct TherapyEntryListView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
let entries: [MoodEntry]
|
let entries: [MoodEntry]
|
||||||
|
|
||||||
@@ -1028,21 +1060,17 @@ struct EntryDetailView: View {
|
|||||||
LabeledContent("Stimmung", value: entry.mood.rawValue)
|
LabeledContent("Stimmung", value: entry.mood.rawValue)
|
||||||
LabeledContent("Stress", value: entry.stress ? "Ja" : "Nein")
|
LabeledContent("Stress", value: entry.stress ? "Ja" : "Nein")
|
||||||
LabeledContent("Sozialkontakt", value: entry.socialContact ? "Ja" : "Nein")
|
LabeledContent("Sozialkontakt", value: entry.socialContact ? "Ja" : "Nein")
|
||||||
LabeledContent("Therapie", value: entry.discussInTherapy ? "Markiert" : "Nicht markiert")
|
Toggle(
|
||||||
|
"In Therapie besprechen",
|
||||||
Button {
|
isOn: therapyDiscussionBinding
|
||||||
entry.discussInTherapy.toggle()
|
|
||||||
try? modelContext.save()
|
|
||||||
} label: {
|
|
||||||
Label(
|
|
||||||
entry.discussInTherapy
|
|
||||||
? "Nicht mehr in Therapie besprechen"
|
|
||||||
: "In Therapie besprechen",
|
|
||||||
systemImage: entry.discussInTherapy
|
|
||||||
? "person.2.wave.2.fill"
|
|
||||||
: "person.2.wave.2"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if entry.discussInTherapy {
|
||||||
|
Section("Zusatznotizen für den Therapeuten") {
|
||||||
|
TextEditor(text: therapistNotesBinding)
|
||||||
|
.frame(minHeight: 100)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !customCategories.isEmpty {
|
if !customCategories.isEmpty {
|
||||||
@@ -1067,8 +1095,7 @@ struct EntryDetailView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
entry.discussInTherapy.toggle()
|
setTherapyDiscussion(!entry.discussInTherapy)
|
||||||
try? modelContext.save()
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: entry.discussInTherapy ? "person.2.wave.2.fill" : "person.2.wave.2")
|
Image(systemName: entry.discussInTherapy ? "person.2.wave.2.fill" : "person.2.wave.2")
|
||||||
}
|
}
|
||||||
@@ -1086,6 +1113,31 @@ struct EntryDetailView: View {
|
|||||||
customCategories = DiaryCategoryStore.load()
|
customCategories = DiaryCategoryStore.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var therapyDiscussionBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { entry.discussInTherapy },
|
||||||
|
set: { setTherapyDiscussion($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var therapistNotesBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { entry.therapistNotes },
|
||||||
|
set: {
|
||||||
|
entry.therapistNotes = $0
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setTherapyDiscussion(_ isEnabled: Bool) {
|
||||||
|
entry.discussInTherapy = isEnabled
|
||||||
|
if !isEnabled {
|
||||||
|
entry.therapistNotes = ""
|
||||||
|
}
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct EditEntryView: View {
|
private struct EditEntryView: View {
|
||||||
@@ -1106,6 +1158,7 @@ private struct EditEntryView: View {
|
|||||||
@State private var stress: Bool
|
@State private var stress: Bool
|
||||||
@State private var transcript: String
|
@State private var transcript: String
|
||||||
@State private var discussInTherapy: Bool
|
@State private var discussInTherapy: Bool
|
||||||
|
@State private var therapistNotes: String
|
||||||
@State private var customCategories: [DiaryCategory]
|
@State private var customCategories: [DiaryCategory]
|
||||||
@State private var customCategoryValues: [String: Bool]
|
@State private var customCategoryValues: [String: Bool]
|
||||||
|
|
||||||
@@ -1123,6 +1176,7 @@ private struct EditEntryView: View {
|
|||||||
_stress = State(initialValue: entry.stress)
|
_stress = State(initialValue: entry.stress)
|
||||||
_transcript = State(initialValue: entry.transcript)
|
_transcript = State(initialValue: entry.transcript)
|
||||||
_discussInTherapy = State(initialValue: entry.discussInTherapy)
|
_discussInTherapy = State(initialValue: entry.discussInTherapy)
|
||||||
|
_therapistNotes = State(initialValue: entry.therapistNotes)
|
||||||
let categories = DiaryCategoryStore.load()
|
let categories = DiaryCategoryStore.load()
|
||||||
_customCategories = State(initialValue: categories)
|
_customCategories = State(initialValue: categories)
|
||||||
_customCategoryValues = State(initialValue: entry.customCategoryValues)
|
_customCategoryValues = State(initialValue: entry.customCategoryValues)
|
||||||
@@ -1143,6 +1197,11 @@ private struct EditEntryView: View {
|
|||||||
Toggle("Sozialkontakt", isOn: $socialContact)
|
Toggle("Sozialkontakt", isOn: $socialContact)
|
||||||
Toggle("Stress", isOn: $stress)
|
Toggle("Stress", isOn: $stress)
|
||||||
Toggle("In Therapie besprechen", isOn: $discussInTherapy)
|
Toggle("In Therapie besprechen", isOn: $discussInTherapy)
|
||||||
|
|
||||||
|
if discussInTherapy {
|
||||||
|
TextField("Zusatznotizen für den Therapeuten", text: $therapistNotes, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !customCategories.isEmpty {
|
if !customCategories.isEmpty {
|
||||||
@@ -1191,6 +1250,9 @@ private struct EditEntryView: View {
|
|||||||
entry.stress = stress
|
entry.stress = stress
|
||||||
entry.transcript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
entry.transcript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
entry.discussInTherapy = discussInTherapy
|
entry.discussInTherapy = discussInTherapy
|
||||||
|
entry.therapistNotes = discussInTherapy
|
||||||
|
? therapistNotes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: ""
|
||||||
entry.customCategoryValuesJSON = DiaryCategoryStore.encodeValues(customCategoryValues)
|
entry.customCategoryValuesJSON = DiaryCategoryStore.encodeValues(customCategoryValues)
|
||||||
|
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ struct HomeView: View {
|
|||||||
@Environment(\.appColorProfile) private var colorProfile
|
@Environment(\.appColorProfile) private var colorProfile
|
||||||
@Query(sort: \MoodEntry.createdAt, order: .reverse) private var entries: [MoodEntry]
|
@Query(sort: \MoodEntry.createdAt, order: .reverse) private var entries: [MoodEntry]
|
||||||
@Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot]
|
@Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot]
|
||||||
|
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
|
||||||
|
@AppStorage(UserProfileStore.healthAssociationKey) private var healthAssociation = ""
|
||||||
@State private var headerMessage = HomeHeaderMessage.random()
|
@State private var headerMessage = HomeHeaderMessage.random()
|
||||||
|
|
||||||
private var recentEntries: [MoodEntry] {
|
private var recentEntries: [MoodEntry] {
|
||||||
@@ -18,17 +20,35 @@ struct HomeView: View {
|
|||||||
return snapshots.filter { $0.createdAt >= start }
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
appHeader
|
appHeader
|
||||||
welcomeCard
|
welcomeCard
|
||||||
|
if !trimmedHealthAssociation.isEmpty {
|
||||||
|
personalAnchorCard
|
||||||
|
}
|
||||||
snapshotButton
|
snapshotButton
|
||||||
|
if showsSafetySupportCard {
|
||||||
|
safetySupportCard
|
||||||
|
}
|
||||||
|
todayCompanionCard
|
||||||
weekOverview
|
weekOverview
|
||||||
|
|
||||||
if let latest = entries.first {
|
if let latest = entries.first {
|
||||||
latestEntry(latest)
|
latestEntry(latest)
|
||||||
|
} else {
|
||||||
|
firstEntryHint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -40,14 +60,10 @@ struct HomeView: View {
|
|||||||
|
|
||||||
private var appHeader: some View {
|
private var appHeader: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "heart.text.square.fill")
|
FeelAloudLogoMark(size: 46)
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.background(colorProfile.accent, in: RoundedRectangle(cornerRadius: 8))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Feel Aloud")
|
Text(greetingTitle)
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
Text(headerMessage.text)
|
Text(headerMessage.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -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 {
|
private var snapshotButton: some View {
|
||||||
Button {
|
Button {
|
||||||
router.presentSnapshot(source: .manual)
|
router.presentSnapshot(source: .manual)
|
||||||
@@ -69,6 +124,8 @@ struct HomeView: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text("🙂")
|
Text("🙂")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
.background(colorProfile.softBackground, in: Circle())
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Momentaufnahme")
|
Text("Momentaufnahme")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -81,28 +138,68 @@ struct HomeView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
.background(.background.opacity(0.94), in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.stroke(colorProfile.accent.opacity(0.10), lineWidth: 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private var welcomeCard: some View {
|
||||||
Button {
|
Button {
|
||||||
router.presentNewEntry(startRecording: false)
|
router.presentNewEntry(startRecording: false)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
Image(systemName: "waveform.and.mic")
|
FeelAloudLogoMark(size: 64, showsBackground: false)
|
||||||
.font(.system(size: 34))
|
.frame(width: 64, height: 64)
|
||||||
.foregroundStyle(.white)
|
.background(.white.opacity(0.20), in: RoundedRectangle(cornerRadius: 18))
|
||||||
.frame(width: 62, height: 62)
|
|
||||||
.background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 18))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
Text("Wie geht es dir gerade?")
|
Text("Wie geht es dir gerade?")
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
Text("Erzähl einfach frei. Dein iPhone sortiert die Angaben lokal für dich.")
|
Text("Sprich oder schreibe, was gerade da ist. Der Rest wird behutsam einsortiert.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.white.opacity(0.86))
|
.foregroundStyle(.white.opacity(0.86))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Image(systemName: "arrow.right.circle.fill")
|
Image(systemName: "arrow.right.circle.fill")
|
||||||
@@ -113,12 +210,17 @@ struct HomeView: View {
|
|||||||
.padding(18)
|
.padding(18)
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: colorProfile.gradient,
|
colors: [
|
||||||
|
colorProfile.accent,
|
||||||
|
colorProfile.secondary,
|
||||||
|
Color(red: 0.93, green: 0.58, blue: 0.67)
|
||||||
|
],
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
),
|
),
|
||||||
in: RoundedRectangle(cornerRadius: 24)
|
in: RoundedRectangle(cornerRadius: 24)
|
||||||
)
|
)
|
||||||
|
.shadow(color: colorProfile.accent.opacity(0.20), radius: 18, y: 10)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityHint("Startet eine lokale Sprachaufnahme")
|
.accessibilityHint("Startet eine lokale Sprachaufnahme")
|
||||||
@@ -151,6 +253,56 @@ struct HomeView: View {
|
|||||||
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var todayCompanionCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Heute kurz prüfen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
todayStep(
|
||||||
|
symbol: entriesContainToday ? "checkmark.circle.fill" : "mic.fill",
|
||||||
|
title: entriesContainToday ? "Eintrag da" : "Eintrag",
|
||||||
|
subtitle: entriesContainToday ? "Heute festgehalten" : "Gedanken sortieren"
|
||||||
|
)
|
||||||
|
todayStep(
|
||||||
|
symbol: snapshotsContainToday ? "checkmark.circle.fill" : "face.smiling",
|
||||||
|
title: snapshotsContainToday ? "Check da" : "Check",
|
||||||
|
subtitle: snapshotsContainToday ? "Moment getrackt" : "Stimmung antippen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entriesContainToday: Bool {
|
||||||
|
entries.contains { Calendar.current.isDateInToday($0.createdAt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var snapshotsContainToday: Bool {
|
||||||
|
snapshots.contains { Calendar.current.isDateInToday($0.createdAt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func todayStep(symbol: String, title: String, subtitle: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(colorProfile.accent)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(colorProfile.softBackground.opacity(0.54), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
private func metric(value: String, label: String, symbol: String) -> some View {
|
private func metric(value: String, label: String, symbol: String) -> some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
@@ -198,20 +350,33 @@ struct HomeView: View {
|
|||||||
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
.background(.background, in: RoundedRectangle(cornerRadius: 18))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var firstEntryHint: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Noch kein Tagebucheintrag")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Der erste Eintrag muss nicht vollständig sein. Ein Gedanke reicht, damit Feel Aloud anfangen kann, Muster sichtbar zu machen.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.background.opacity(0.86), in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
}
|
||||||
|
|
||||||
private var averageSnapshotText: String {
|
private var averageSnapshotText: String {
|
||||||
guard !recentSnapshots.isEmpty else { return "–" }
|
let scores = recentSnapshots.map(\.rating.score) + recentEntries.map(\.mood.score)
|
||||||
let average = recentSnapshots.map(\.rating.score).reduce(0, +)
|
guard !scores.isEmpty else { return "–" }
|
||||||
/ Double(recentSnapshots.count)
|
let average = scores.reduce(0, +) / Double(scores.count)
|
||||||
return SnapshotRating.label(for: average)
|
return SnapshotRating.label(for: average)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum HomeHeaderMessage: String, CaseIterable {
|
private enum HomeHeaderMessage: String, CaseIterable {
|
||||||
case one = "Ein kleiner Moment reicht, um dich selbst besser zu verstehen."
|
case one = "Ein kleiner Moment reicht, um wieder bei dir anzukommen."
|
||||||
case two = "Du musst es nicht perfekt sagen. Fang einfach an."
|
case two = "Du musst es nicht perfekt sagen. Echt reicht."
|
||||||
case three = "Heute zählt nicht laut oder leise, sondern ehrlich."
|
case three = "Heute zählt nicht laut oder leise, sondern ehrlich."
|
||||||
case four = "Ein Gedanke nach dem anderen ist genug."
|
case four = "Ein Gedanke nach dem anderen ist genug."
|
||||||
case five = "Deine Notizen sind ein Werkzeug, kein Urteil."
|
case five = "Deine Notizen begleiten dich, sie bewerten dich nicht."
|
||||||
|
|
||||||
var text: String { rawValue }
|
var text: String { rawValue }
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ private struct GeneratedDiaryEntry {
|
|||||||
var stress: Bool
|
var stress: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
private struct GeneratedOnboardingEssence {
|
||||||
|
@Guide(description: "Kurze Kernessenz der Nutzeraussage als neutraler deutscher Begriff oder kurze Nominalphrase, ohne Satzanfang, ohne Ich-Form, maximal 4 Wörter")
|
||||||
|
var essence: String
|
||||||
|
}
|
||||||
|
|
||||||
enum MoodAnalysisError: LocalizedError {
|
enum MoodAnalysisError: LocalizedError {
|
||||||
case emptyTranscript
|
case emptyTranscript
|
||||||
case modelUnavailable
|
case modelUnavailable
|
||||||
@@ -46,6 +52,46 @@ enum MoodAnalysisError: LocalizedError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct MoodAnalysisService {
|
struct MoodAnalysisService {
|
||||||
|
static func extractOnboardingEssence(from text: String) async throws -> String {
|
||||||
|
let cleanText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !cleanText.isEmpty else {
|
||||||
|
throw MoodAnalysisError.emptyTranscript
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = SystemLanguageModel.default
|
||||||
|
guard model.availability == .available else {
|
||||||
|
throw MoodAnalysisError.modelUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = LanguageModelSession(
|
||||||
|
model: model,
|
||||||
|
instructions: """
|
||||||
|
Du extrahierst ausschließlich die Kernessenz aus einer kurzen deutschen Antwort im Onboarding einer privaten Mental-Health-Tagebuch-App.
|
||||||
|
Formuliere die Essenz als konkreten, wiederverwendungsfertigen Begriff oder sehr kurze Nominalphrase.
|
||||||
|
Bleibe eng an den genannten Worten und Bedürfnissen der Person.
|
||||||
|
Gib niemals generische App- oder Therapiebegriffe wie Selbstreflexion, Achtsamkeit, Wohlbefinden, Therapie, Gesundheit oder Stimmung aus, wenn diese nicht ausdrücklich die Kernaussage sind.
|
||||||
|
Keine Ich-Form, kein ganzer Satz, keine Anführungszeichen, keine medizinische Einschätzung, kein Rat.
|
||||||
|
Beispiele:
|
||||||
|
"wenn ich endlich schmerzfrei bin" -> "Schmerzfreiheit"
|
||||||
|
"ohne rückenschmerzen endlich wieder am leben teilnehmen können" -> "Teilhabe ohne Rückenschmerzen"
|
||||||
|
"dass ich wieder schlafen kann" -> "erholsamer Schlaf"
|
||||||
|
"mehr ruhe in meinem kopf" -> "Ruhe im Kopf"
|
||||||
|
"ich will mich nicht mehr so allein fühlen" -> "Verbundenheit"
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
let response = try await session.respond(
|
||||||
|
to: "Extrahiere die Kernessenz aus dieser Antwort:\\n\\n\\(cleanText)",
|
||||||
|
generating: GeneratedOnboardingEssence.self
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalizedEssence(response.content.essence, fallback: cleanText)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fallbackOnboardingEssence(from text: String) -> String {
|
||||||
|
fallbackEssence(from: text)
|
||||||
|
}
|
||||||
|
|
||||||
static func analyze(_ transcript: String) async throws -> DraftMoodEntry {
|
static func analyze(_ transcript: String) async throws -> DraftMoodEntry {
|
||||||
let cleanTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
let cleanTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !cleanTranscript.isEmpty else {
|
guard !cleanTranscript.isEmpty else {
|
||||||
@@ -89,4 +135,124 @@ struct MoodAnalysisService {
|
|||||||
transcript: cleanTranscript
|
transcript: cleanTranscript
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func normalizedEssence(_ essence: String, fallback: String) -> String {
|
||||||
|
let trimmed = essence
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\"“”„"))
|
||||||
|
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
return fallbackEssence(from: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
let words = trimmed.split(separator: " ").prefix(4).map(String.init)
|
||||||
|
let compactEssence = words.joined(separator: " ")
|
||||||
|
guard isPlausibleOnboardingEssence(compactEssence, for: fallback) else {
|
||||||
|
return fallbackEssence(from: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
return compactEssence
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isPlausibleOnboardingEssence(_ essence: String, for source: String) -> Bool {
|
||||||
|
let normalizedEssence = essence
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
let normalizedSource = source.lowercased()
|
||||||
|
|
||||||
|
guard !normalizedEssence.isEmpty else { return false }
|
||||||
|
|
||||||
|
let genericTerms: Set<String> = [
|
||||||
|
"selbstreflexion",
|
||||||
|
"achtsamkeit",
|
||||||
|
"wohlbefinden",
|
||||||
|
"therapie",
|
||||||
|
"gesundheit",
|
||||||
|
"stimmung",
|
||||||
|
"emotionale gesundheit",
|
||||||
|
"mentale gesundheit"
|
||||||
|
]
|
||||||
|
|
||||||
|
if genericTerms.contains(normalizedEssence), !normalizedSource.contains(normalizedEssence) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedSource.contains("rücken") || normalizedSource.contains("schmerz") {
|
||||||
|
return normalizedEssence.contains("schmerz")
|
||||||
|
|| normalizedEssence.contains("rücken")
|
||||||
|
|| normalizedEssence.contains("teilhabe")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fallbackEssence(from source: String) -> String {
|
||||||
|
let normalizedSource = source
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
|
||||||
|
guard !normalizedSource.isEmpty else { return "" }
|
||||||
|
|
||||||
|
if normalizedSource.contains("rücken") && normalizedSource.contains("schmerz") && normalizedSource.contains("teilnehmen") {
|
||||||
|
return "Teilhabe ohne Rückenschmerzen"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedSource.contains("rücken") && normalizedSource.contains("schmerz") {
|
||||||
|
return "Rückenschmerzfreiheit"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedSource.contains("schmerzfrei")
|
||||||
|
|| normalizedSource.contains("keine schmerzen")
|
||||||
|
|| normalizedSource.contains("ohne schmerzen") {
|
||||||
|
return "Schmerzfreiheit"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedSource.contains("schlafen") {
|
||||||
|
return "erholsamer Schlaf"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedSource.contains("allein") || normalizedSource.contains("einsam") {
|
||||||
|
return "Verbundenheit"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedSource.contains("ruhe") {
|
||||||
|
return "Ruhe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedSource.contains("kraft") {
|
||||||
|
return "Kraft"
|
||||||
|
}
|
||||||
|
|
||||||
|
let separators = CharacterSet(charactersIn: ".,;:!?()[]{}\"“”„")
|
||||||
|
let cleaned = normalizedSource
|
||||||
|
.components(separatedBy: separators)
|
||||||
|
.joined(separator: " ")
|
||||||
|
|
||||||
|
let fillerWords: Set<String> = [
|
||||||
|
"ich", "mich", "mir", "mein", "meine", "meinen", "meiner",
|
||||||
|
"bin", "ist", "sind", "sein", "werde", "werden", "kann", "können",
|
||||||
|
"habe", "haben", "endlich", "wieder", "einfach", "nur", "dann",
|
||||||
|
"dass", "wenn", "es", "für", "mit", "und", "oder", "der", "die", "das",
|
||||||
|
"am", "im", "in", "an", "zu", "zur", "zum", "vom", "von"
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = cleaned
|
||||||
|
.split(separator: " ")
|
||||||
|
.map(String.init)
|
||||||
|
.filter { !fillerWords.contains($0) }
|
||||||
|
.prefix(4)
|
||||||
|
.joined(separator: " ")
|
||||||
|
|
||||||
|
guard !result.isEmpty else {
|
||||||
|
return source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
.split(separator: " ")
|
||||||
|
.map { word in
|
||||||
|
guard let first = word.first else { return "" }
|
||||||
|
return first.uppercased() + word.dropFirst()
|
||||||
|
}
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ struct DraftMoodEntry: Sendable {
|
|||||||
var stress = false
|
var stress = false
|
||||||
var transcript = ""
|
var transcript = ""
|
||||||
var discussInTherapy = false
|
var discussInTherapy = false
|
||||||
|
var therapistNotes = ""
|
||||||
var customCategoryValues: [String: Bool] = [:]
|
var customCategoryValues: [String: Bool] = [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ final class MoodEntry {
|
|||||||
var stress = false
|
var stress = false
|
||||||
var transcript = ""
|
var transcript = ""
|
||||||
var discussInTherapy = false
|
var discussInTherapy = false
|
||||||
|
var therapistNotes = ""
|
||||||
var customCategoryValuesJSON = "{}"
|
var customCategoryValuesJSON = "{}"
|
||||||
|
|
||||||
init(draft: DraftMoodEntry, createdAt: Date = .now) {
|
init(draft: DraftMoodEntry, createdAt: Date = .now) {
|
||||||
@@ -129,6 +131,9 @@ final class MoodEntry {
|
|||||||
stress = !draft.musicInHead && draft.stress
|
stress = !draft.musicInHead && draft.stress
|
||||||
transcript = draft.transcript
|
transcript = draft.transcript
|
||||||
discussInTherapy = draft.discussInTherapy
|
discussInTherapy = draft.discussInTherapy
|
||||||
|
therapistNotes = draft.discussInTherapy
|
||||||
|
? draft.therapistNotes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: ""
|
||||||
customCategoryValuesJSON = DiaryCategoryStore.encodeValues(draft.customCategoryValues)
|
customCategoryValuesJSON = DiaryCategoryStore.encodeValues(draft.customCategoryValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,13 +116,15 @@ enum WidgetSnapshotImport {
|
|||||||
private static let key = "pendingWidgetSnapshots"
|
private static let key = "pendingWidgetSnapshots"
|
||||||
|
|
||||||
static func consumePendingSnapshots() -> [MoodSnapshot] {
|
static func consumePendingSnapshots() -> [MoodSnapshot] {
|
||||||
guard let defaults = UserDefaults(suiteName: appGroupIdentifier),
|
let pendingSnapshots = readableDefaults.flatMap { defaults in
|
||||||
let data = defaults.data(forKey: key),
|
let snapshots = decodePendingSnapshots(from: defaults)
|
||||||
let pendingSnapshots = try? JSONDecoder().decode([PendingWidgetSnapshot].self, from: data) else {
|
defaults.removeObject(forKey: key)
|
||||||
return []
|
return snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
defaults.removeObject(forKey: key)
|
guard !pendingSnapshots.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
return pendingSnapshots.compactMap { pendingSnapshot in
|
return pendingSnapshots.compactMap { pendingSnapshot in
|
||||||
guard let rating = SnapshotRating(rawValue: pendingSnapshot.ratingRawValue) else {
|
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 {
|
private struct PendingWidgetSnapshot: Codable {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import AppIntents
|
import AppIntents
|
||||||
|
import AuthenticationServices
|
||||||
import FoundationModels
|
import FoundationModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -13,18 +14,24 @@ struct SettingsView: View {
|
|||||||
@Query(sort: \MoodEntry.createdAt) private var entries: [MoodEntry]
|
@Query(sort: \MoodEntry.createdAt) private var entries: [MoodEntry]
|
||||||
|
|
||||||
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
||||||
|
@AppStorage(UserProfileStore.isLoggedInKey) private var isLoggedIn = false
|
||||||
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
|
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
|
||||||
@AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data()
|
@AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data()
|
||||||
|
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
|
||||||
|
@AppStorage(UserProfileStore.accountEmailKey) private var accountEmail = ""
|
||||||
|
@AppStorage(AccountBackendStore.backendURLKey) private var backendURL = ""
|
||||||
@AppStorage("snapshotRemindersEnabled") private var remindersEnabled = false
|
@AppStorage("snapshotRemindersEnabled") private var remindersEnabled = false
|
||||||
@AppStorage("snapshotReminderCount") private var reminderCount = 3
|
@AppStorage("snapshotReminderCount") private var reminderCount = 3
|
||||||
@AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60
|
@AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60
|
||||||
@AppStorage("snapshotReminderTime2") private var reminderTime2 = 15 * 60
|
@AppStorage("snapshotReminderTime2") private var reminderTime2 = 15 * 60
|
||||||
@AppStorage("snapshotReminderTime3") private var reminderTime3 = 20 * 60
|
@AppStorage("snapshotReminderTime3") private var reminderTime3 = 20 * 60
|
||||||
@AppStorage("appLockEnabled") private var appLockEnabled = false
|
@AppStorage("appLockEnabled") private var appLockEnabled = false
|
||||||
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.lavender.rawValue
|
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue
|
||||||
@AppStorage("forceLightAppearance") private var forceLightAppearance = false
|
@AppStorage("forceLightAppearance") private var forceLightAppearance = true
|
||||||
|
@AppStorage("iCloudSyncRequested") private var iCloudSyncRequested = false
|
||||||
@AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false
|
@AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false
|
||||||
@AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = false
|
@AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = false
|
||||||
|
@AppStorage(UserProfileStore.showOnboardingOnNextLaunchKey) private var showOnboardingOnNextLaunch = false
|
||||||
|
|
||||||
@State private var notificationStatus = "Wird geprüft …"
|
@State private var notificationStatus = "Wird geprüft …"
|
||||||
@State private var notificationMessage: String?
|
@State private var notificationMessage: String?
|
||||||
@@ -137,6 +144,16 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
developmentSettings
|
||||||
|
} label: {
|
||||||
|
settingsNavigationRow(
|
||||||
|
title: "Entwicklung",
|
||||||
|
subtitle: "Testfunktionen und Debug-Hilfen",
|
||||||
|
systemImage: "hammer"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
appInfoSettings
|
appInfoSettings
|
||||||
} label: {
|
} label: {
|
||||||
@@ -178,7 +195,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
Button("Abbrechen", role: .cancel) { }
|
Button("Abbrechen", role: .cancel) { }
|
||||||
} message: {
|
} message: {
|
||||||
Text("Deine Tagebucheinträge und Momentaufnahmen bleiben erhalten. Nur Profilangaben und der abgeschlossene Onboarding-Status werden zurückgesetzt.")
|
Text("Deine Tagebucheinträge, Momentaufnahmen und Accountdaten bleiben erhalten. Du landest danach auf der Login-Seite.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +215,42 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
LabeledContent("Account", value: "Apple-ID / iCloud")
|
LabeledContent("Account", value: currentAccountProvider.displayName)
|
||||||
|
if !accountEmail.isEmpty {
|
||||||
|
LabeledContent("E-Mail", value: accountEmail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Optionale Verknüpfung") {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
SignInWithAppleButton(.signIn) { request in
|
||||||
|
request.requestedScopes = [.fullName, .email]
|
||||||
|
} onCompletion: { result in
|
||||||
|
handleAppleSignIn(result)
|
||||||
|
}
|
||||||
|
.signInWithAppleButtonStyle(.black)
|
||||||
|
.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))
|
||||||
|
|
||||||
|
Text("Diese Optionen sind freiwillig. Sie dienen später nur dazu, Zugang und Wiederherstellung komfortabler zu machen.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Premium") {
|
Section("Premium") {
|
||||||
@@ -226,7 +278,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Section("Zugang") {
|
Section("Zugang") {
|
||||||
Button {
|
Button {
|
||||||
profileMessage = "FeelAloud verwendet aktuell kein eigenes Passwort. Der Zugang läuft über dein Gerät, Face ID und später über deine Apple-ID/iCloud. Ein App-Passwort kann erst mit einem echten Login-System ergänzt werden."
|
profileMessage = "Passwort ändern ist nur für E-Mail-Accounts sinnvoll. Dafür braucht FeelAloud zuerst einen echten Auth-Server mit sicherer Passwortspeicherung und Reset-Mail-Funktion."
|
||||||
} label: {
|
} label: {
|
||||||
Label("Passwort ändern", systemImage: "key")
|
Label("Passwort ändern", systemImage: "key")
|
||||||
}
|
}
|
||||||
@@ -334,6 +386,13 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Speicherort") {
|
||||||
|
LabeledContent("Standard", value: "Lokal auf diesem Gerät")
|
||||||
|
Text("Tagebuch, Momentaufnahmen und Therapiedaten werden im App-Speicher dieses Apple-Geräts abgelegt. iOS schützt diesen Speicher über die Geräteverschlüsselung, sobald ein Gerätecode eingerichtet ist.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Section("Lokale KI") {
|
Section("Lokale KI") {
|
||||||
LabeledContent("Apple-Modell") {
|
LabeledContent("Apple-Modell") {
|
||||||
Label(
|
Label(
|
||||||
@@ -356,8 +415,12 @@ struct SettingsView: View {
|
|||||||
private var syncSettings: some View {
|
private var syncSettings: some View {
|
||||||
List {
|
List {
|
||||||
Section("iCloud Sync") {
|
Section("iCloud Sync") {
|
||||||
|
Toggle("Eigene iCloud verwenden", isOn: $iCloudSyncRequested)
|
||||||
LabeledContent("Account", value: iCloudStatus)
|
LabeledContent("Account", value: iCloudStatus)
|
||||||
Text("Momentaufnahmen und Tagebucheinträge werden über die private iCloud-Datenbank dieses Apple-Accounts synchronisiert, sobald iCloud Drive und CloudKit für die App aktiviert sind.")
|
Text(iCloudSyncRequested
|
||||||
|
? "iCloud-Sync ist vorgemerkt. Die App bleibt lokal, bis iCloud Drive und die CloudKit-Capability für Feel Aloud eingerichtet sind."
|
||||||
|
: "Solange diese Option aus ist, bleiben deine Daten ausschließlich lokal auf diesem Gerät."
|
||||||
|
)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -424,15 +487,13 @@ struct SettingsView: View {
|
|||||||
private var premiumSettings: some View {
|
private var premiumSettings: some View {
|
||||||
List {
|
List {
|
||||||
Section("Unterstützen") {
|
Section("Unterstützen") {
|
||||||
Toggle("Premium features", isOn: premiumFeaturesToggle)
|
|
||||||
|
|
||||||
Label(
|
Label(
|
||||||
premiumFeaturesEnabled ? "Bonusfunktionen aktiv" : "Bonusfunktionen gesperrt",
|
premiumFeaturesEnabled ? "Bonusfunktionen aktiv" : "Bonusfunktionen gesperrt",
|
||||||
systemImage: premiumFeaturesEnabled ? "checkmark.seal.fill" : "heart"
|
systemImage: premiumFeaturesEnabled ? "checkmark.seal.fill" : "heart"
|
||||||
)
|
)
|
||||||
.foregroundStyle(premiumFeaturesEnabled ? .green : .secondary)
|
.foregroundStyle(premiumFeaturesEnabled ? .green : .secondary)
|
||||||
|
|
||||||
Text("Testphase: Der Schalter simuliert den späteren Unterstützen-Kauf. Projektmappen und der Therapeuten-Excel-Export sind als Bonusfunktionen vorgesehen.")
|
Text("Premium wird später über den App Store freigeschaltet. Testschalter liegen jetzt gesammelt unter Einstellungen → Entwicklung.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
@@ -450,6 +511,38 @@ struct SettingsView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var developmentSettings: some View {
|
||||||
|
List {
|
||||||
|
Section("Premium-Test") {
|
||||||
|
Toggle("Premium features simulieren", isOn: premiumFeaturesToggle)
|
||||||
|
|
||||||
|
Text("Nur für die Testphase: Dieser Schalter simuliert den späteren Unterstützen-Kauf, ohne eine echte App-Store-Transaktion auszulösen.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Onboarding-Test") {
|
||||||
|
Button {
|
||||||
|
showOnboardingOnNextLaunch = true
|
||||||
|
profileMessage = "Onboarding wird beim nächsten App-Start wieder angezeigt."
|
||||||
|
} label: {
|
||||||
|
Label("Onboarding beim nächsten Start anzeigen", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
|
||||||
|
if showOnboardingOnNextLaunch {
|
||||||
|
Label("Für den nächsten App-Start vorgemerkt", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Die aktuellen App-Daten bleiben erhalten. Nur der abgeschlossene Onboarding-Status wird beim nächsten Start zurückgesetzt.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Entwicklung")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
private var shortcutsSettings: some View {
|
private var shortcutsSettings: some View {
|
||||||
List {
|
List {
|
||||||
Section("Action Button") {
|
Section("Action Button") {
|
||||||
@@ -495,6 +588,10 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentAccountProvider: AccountProvider {
|
||||||
|
AccountProvider(rawValue: accountProviderRaw) ?? .local
|
||||||
|
}
|
||||||
|
|
||||||
private var profileAvatar: some View {
|
private var profileAvatar: some View {
|
||||||
Group {
|
Group {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
@@ -724,9 +821,81 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func logoutProfile() {
|
private func logoutProfile() {
|
||||||
displayName = ""
|
UserProfileStore.markLoggedOut()
|
||||||
profileImageData = Data()
|
isLoggedIn = false
|
||||||
hasCompletedOnboarding = false
|
}
|
||||||
|
|
||||||
|
private func handleAppleSignIn(_ result: Result<ASAuthorization, Error>) {
|
||||||
|
switch result {
|
||||||
|
case .success(let authorization):
|
||||||
|
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
|
||||||
|
profileMessage = "Apple hat keine gültigen Accountdaten zurückgegeben."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let identityTokenData = credential.identityToken,
|
||||||
|
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
|
||||||
|
profileMessage = "Apple hat kein Identity Token zurückgegeben."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UserProfileStore.saveAppleAccount(
|
||||||
|
userID: credential.user,
|
||||||
|
email: credential.email,
|
||||||
|
fullName: credential.fullName
|
||||||
|
)
|
||||||
|
|
||||||
|
accountProviderRaw = AccountProvider.apple.rawValue
|
||||||
|
if let email = credential.email, !email.isEmpty {
|
||||||
|
accountEmail = email
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatter = PersonNameComponentsFormatter()
|
||||||
|
let appleName = credential.fullName.map { formatter.string(from: $0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? ""
|
||||||
|
if !appleName.isEmpty {
|
||||||
|
displayName = appleName
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await syncAppleAccountWithBackend(
|
||||||
|
identityToken: identityToken,
|
||||||
|
authorizationCode: credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) },
|
||||||
|
email: credential.email,
|
||||||
|
fullName: credential.fullName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
profileMessage = "Apple Login konnte nicht abgeschlossen werden: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func syncAppleAccountWithBackend(
|
||||||
|
identityToken: String,
|
||||||
|
authorizationCode: String?,
|
||||||
|
email: String?,
|
||||||
|
fullName: PersonNameComponents?
|
||||||
|
) async {
|
||||||
|
guard AccountBackendStore.backendURL != nil else {
|
||||||
|
profileMessage = "Apple Account wurde lokal verknüpft. Trage eine Backend-URL ein, um ihn serverseitig zu synchronisieren."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let formatter = PersonNameComponentsFormatter()
|
||||||
|
let session = try await AccountBackendClient().signInWithApple(
|
||||||
|
identityToken: identityToken,
|
||||||
|
authorizationCode: authorizationCode,
|
||||||
|
email: email,
|
||||||
|
displayName: fullName.map { formatter.string(from: $0) }
|
||||||
|
)
|
||||||
|
AccountBackendStore.save(session: session)
|
||||||
|
accountProviderRaw = AccountProvider.apple.rawValue
|
||||||
|
accountEmail = session.user.email ?? accountEmail
|
||||||
|
displayName = session.user.displayName
|
||||||
|
profileMessage = "Apple Account wurde mit dem Backend synchronisiert."
|
||||||
|
} catch {
|
||||||
|
profileMessage = "Apple Account wurde lokal verknüpft, aber das Backend konnte nicht synchronisieren: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,13 +169,19 @@ final class SpeechRecognizer: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func requestMicrophonePermission() async -> Bool {
|
private func requestMicrophonePermission() async -> Bool {
|
||||||
switch AVAudioApplication.shared.recordPermission {
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||||
case .granted:
|
case .authorized:
|
||||||
true
|
true
|
||||||
case .denied:
|
case .denied:
|
||||||
false
|
false
|
||||||
case .undetermined:
|
case .restricted:
|
||||||
await AVAudioApplication.requestRecordPermission()
|
false
|
||||||
|
case .notDetermined:
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { isAllowed in
|
||||||
|
continuation.resume(returning: isAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@unknown default:
|
@unknown default:
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import AppIntents
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
struct StartDiaryEntryIntent: AppIntent, TargetContentProvidingIntent {
|
struct StartDiaryEntryIntent: AppIntent, TargetContentProvidingIntent {
|
||||||
static let title: LocalizedStringResource = "Eintrag aufnehmen"
|
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 {
|
struct FeelAloudShortcuts: AppShortcutsProvider {
|
||||||
static let shortcutTileColor: ShortcutTileColor = .purple
|
static let shortcutTileColor: ShortcutTileColor = .purple
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ struct TherapySupportView: View {
|
|||||||
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var projectNotes: [TherapyProjectNote]
|
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var projectNotes: [TherapyProjectNote]
|
||||||
@Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var attachments: [TherapyProjectAttachment]
|
@Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var attachments: [TherapyProjectAttachment]
|
||||||
@Query(sort: \EmergencyPlan.updatedAt, order: .reverse) private var plans: [EmergencyPlan]
|
@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 selectedKind: TherapyItemKind?
|
||||||
@State private var editedItem: TherapyItem?
|
@State private var editedItem: TherapyItem?
|
||||||
@@ -19,6 +21,8 @@ struct TherapySupportView: View {
|
|||||||
@State private var showsNewProject = false
|
@State private var showsNewProject = false
|
||||||
@State private var showsPremiumPaywall = false
|
@State private var showsPremiumPaywall = false
|
||||||
@State private var infoKind: TherapyItemKind?
|
@State private var infoKind: TherapyItemKind?
|
||||||
|
@State private var showsEmergencyPlan = false
|
||||||
|
@State private var showsSessionMode = false
|
||||||
|
|
||||||
private var activeItems: [TherapyItem] {
|
private var activeItems: [TherapyItem] {
|
||||||
items.filter { item in
|
items.filter { item in
|
||||||
@@ -32,10 +36,6 @@ struct TherapySupportView: View {
|
|||||||
projects.filter { !$0.isArchived }
|
projects.filter { !$0.isArchived }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showsProjects: Bool {
|
|
||||||
selectedKind == nil || selectedKind == .project
|
|
||||||
}
|
|
||||||
|
|
||||||
private var showsItems: Bool {
|
private var showsItems: Bool {
|
||||||
selectedKind == nil || selectedKind == .goal || selectedKind == .task
|
selectedKind == nil || selectedKind == .goal || selectedKind == .task
|
||||||
}
|
}
|
||||||
@@ -48,88 +48,76 @@ struct TherapySupportView: View {
|
|||||||
items.filter { !$0.isArchived && $0.isCompleted && $0.kind != .project }.count
|
items.filter { !$0.isArchived && $0.isCompleted && $0.kind != .project }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var therapyEntries: [MoodEntry] {
|
||||||
|
diaryEntries.filter { $0.discussInTherapy }
|
||||||
|
}
|
||||||
|
|
||||||
private var plan: EmergencyPlan? {
|
private var plan: EmergencyPlan? {
|
||||||
plans.first
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section("Notfall") {
|
Section {
|
||||||
if let plan {
|
HStack(spacing: 10) {
|
||||||
NavigationLink {
|
Button {
|
||||||
EmergencyPlanView(plan: plan)
|
showsEmergencyPlan = true
|
||||||
} label: {
|
} label: {
|
||||||
SettingsLikeRow(
|
TherapyQuickAccessTile(
|
||||||
title: "Notfallplan",
|
title: "Notfallplan",
|
||||||
subtitle: emergencyPlanSubtitle(for: plan),
|
subtitle: plan.map(emergencyPlanSubtitle(for:)) ?? "Wird angelegt",
|
||||||
systemImage: "cross.case"
|
systemImage: "cross.case"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.buttonStyle(.plain)
|
||||||
}
|
.disabled(plan == nil)
|
||||||
|
|
||||||
Section("Therapiearbeit") {
|
Button {
|
||||||
therapyOverview
|
showsSessionMode = true
|
||||||
|
} label: {
|
||||||
Picker("Filter", selection: $selectedKind) {
|
TherapyQuickAccessTile(
|
||||||
Text("Alle").tag(nil as TherapyItemKind?)
|
title: "Sitzung",
|
||||||
ForEach([TherapyItemKind.goal, .task, .project]) { kind in
|
subtitle: "Kompakter Überblick",
|
||||||
Text(kind.rawValue).tag(kind as TherapyItemKind?)
|
systemImage: "rectangle.stack.badge.person.crop"
|
||||||
}
|
|
||||||
}
|
|
||||||
.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.")
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
} else {
|
}
|
||||||
ForEach(activeProjects) { project in
|
|
||||||
|
Section("Projektordner") {
|
||||||
|
if premiumFeaturesEnabled {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
TherapyProjectDetailView(project: project)
|
TherapyProjectListView()
|
||||||
} label: {
|
} label: {
|
||||||
TherapyProjectRow(
|
SettingsLikeRow(
|
||||||
project: project,
|
title: "Projektordner",
|
||||||
noteCount: projectNotes.filter { $0.projectID == project.id }.count,
|
subtitle: "\(activeProjects.count) Projektordner",
|
||||||
attachmentCount: attachments.filter { $0.projectID == project.id }.count
|
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 {
|
} else {
|
||||||
PremiumLockedRow(
|
PremiumLockedRow(
|
||||||
title: "Projektmappen freischalten",
|
title: "Projektordner freischalten",
|
||||||
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
|
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
|
||||||
systemImage: "folder.badge.plus"
|
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 {
|
if showsItems {
|
||||||
Section("Ziele und Aufgaben") {
|
TherapySubsectionHeader(
|
||||||
if activeItems.isEmpty {
|
title: "Ziele und Aufgaben",
|
||||||
ContentUnavailableView(
|
subtitle: "Konkrete Arbeitspunkte aus Alltag und Sitzungen"
|
||||||
"Noch nichts angelegt",
|
|
||||||
systemImage: "target",
|
|
||||||
description: Text("Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.")
|
|
||||||
)
|
)
|
||||||
.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 {
|
} else {
|
||||||
ForEach(activeItems) { item in
|
ForEach(activeItems) { item in
|
||||||
Button {
|
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")
|
.navigationTitle("Therapie")
|
||||||
|
.navigationDestination(isPresented: $showsEmergencyPlan) {
|
||||||
|
if let plan {
|
||||||
|
EmergencyPlanView(plan: plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $showsSessionMode) {
|
||||||
|
TherapySessionView(
|
||||||
|
therapyEntries: therapyEntries,
|
||||||
|
openItems: sessionItems,
|
||||||
|
recentSnapshots: recentSnapshots,
|
||||||
|
plan: plan
|
||||||
|
)
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
@@ -235,7 +268,7 @@ struct TherapySupportView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showsPremiumPaywall) {
|
.sheet(isPresented: $showsPremiumPaywall) {
|
||||||
PremiumPaywallView(
|
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."
|
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 {
|
private var therapyOverview: some View {
|
||||||
LazyVGrid(
|
LazyVGrid(
|
||||||
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
|
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
|
||||||
spacing: 8
|
spacing: 8
|
||||||
) {
|
) {
|
||||||
TherapyMetricView(
|
|
||||||
title: "Mappen",
|
|
||||||
value: premiumFeaturesEnabled ? "\(activeProjects.count)" : "Bonus",
|
|
||||||
systemImage: premiumFeaturesEnabled ? "folder" : "lock"
|
|
||||||
)
|
|
||||||
TherapyMetricView(title: "Offen", value: "\(openItemCount)", systemImage: "circle")
|
TherapyMetricView(title: "Offen", value: "\(openItemCount)", systemImage: "circle")
|
||||||
TherapyMetricView(title: "Erledigt", value: "\(completedItemCount)", systemImage: "checkmark.circle.fill")
|
TherapyMetricView(title: "Erledigt", value: "\(completedItemCount)", systemImage: "checkmark.circle.fill")
|
||||||
}
|
}
|
||||||
@@ -263,14 +291,6 @@ struct TherapySupportView: View {
|
|||||||
|
|
||||||
private var kindInfoRow: some View {
|
private var kindInfoRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button {
|
|
||||||
infoKind = .project
|
|
||||||
} label: {
|
|
||||||
Label("Projektmappe", systemImage: "questionmark.circle")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
|
|
||||||
ForEach([TherapyItemKind.goal, .task]) { kind in
|
ForEach([TherapyItemKind.goal, .task]) { kind in
|
||||||
Button {
|
Button {
|
||||||
infoKind = kind
|
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 {
|
private struct TherapyProjectDetailView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var allNotes: [TherapyProjectNote]
|
@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 {
|
private struct EmergencyPlanView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Bindable var plan: EmergencyPlan
|
@Bindable var plan: EmergencyPlan
|
||||||
|
|||||||
@@ -329,7 +329,9 @@ private struct WorkbookBuilder {
|
|||||||
.text("Sozialkontakt"),
|
.text("Sozialkontakt"),
|
||||||
.text("Musik"),
|
.text("Musik"),
|
||||||
.text("Stress"),
|
.text("Stress"),
|
||||||
.text("Text kurz")
|
.text("Text kurz"),
|
||||||
|
.text("Therapie"),
|
||||||
|
.text("TherapeutenNotizen")
|
||||||
])
|
])
|
||||||
rows += entries.map { compactDiaryRow(for: $0) }
|
rows += entries.map { compactDiaryRow(for: $0) }
|
||||||
rows.append([.text(""), .text("")])
|
rows.append([.text(""), .text("")])
|
||||||
@@ -339,13 +341,14 @@ private struct WorkbookBuilder {
|
|||||||
.text("Ohrwurm"),
|
.text("Ohrwurm"),
|
||||||
.text("Leidensdruck"),
|
.text("Leidensdruck"),
|
||||||
.text("Gegenmaßnahme"),
|
.text("Gegenmaßnahme"),
|
||||||
.text("Gesprochener Text")
|
.text("Gesprochener Text"),
|
||||||
|
.text("TherapeutenNotizen")
|
||||||
])
|
])
|
||||||
rows += entries.map { diaryDetailRow(for: $0) }
|
rows += entries.map { diaryDetailRow(for: $0) }
|
||||||
|
|
||||||
return worksheet(
|
return worksheet(
|
||||||
rows: rows,
|
rows: rows,
|
||||||
columnWidths: [20, 16, 10, 18, 16, 42, 14],
|
columnWidths: [20, 16, 10, 18, 16, 42, 14, 34, 42],
|
||||||
headerRow: true,
|
headerRow: true,
|
||||||
autoFilter: false,
|
autoFilter: false,
|
||||||
drawingRelationshipID: hasChart ? "rId1" : nil,
|
drawingRelationshipID: hasChart ? "rId1" : nil,
|
||||||
@@ -426,7 +429,9 @@ private struct WorkbookBuilder {
|
|||||||
.text("Gegenmaßnahmen"),
|
.text("Gegenmaßnahmen"),
|
||||||
.text("GegenmaßnahmeAktivität"),
|
.text("GegenmaßnahmeAktivität"),
|
||||||
.text("Stress"),
|
.text("Stress"),
|
||||||
.text("GesprochenerText")
|
.text("GesprochenerText"),
|
||||||
|
.text("InTherapieBesprechen"),
|
||||||
|
.text("TherapeutenNotizen")
|
||||||
]]
|
]]
|
||||||
|
|
||||||
rows += entries.map { entry in
|
rows += entries.map { entry in
|
||||||
@@ -435,7 +440,7 @@ private struct WorkbookBuilder {
|
|||||||
|
|
||||||
return worksheet(
|
return worksheet(
|
||||||
rows: rows,
|
rows: rows,
|
||||||
columnWidths: [14, 11, 18, 10, 16, 28, 18, 17, 17, 19, 32, 12, 55],
|
columnWidths: [14, 11, 18, 10, 16, 28, 18, 17, 17, 19, 32, 12, 55, 18, 44],
|
||||||
headerRow: true,
|
headerRow: true,
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
drawingRelationshipID: nil,
|
drawingRelationshipID: nil,
|
||||||
@@ -461,7 +466,9 @@ private struct WorkbookBuilder {
|
|||||||
.text(entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : ""),
|
.text(entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : ""),
|
||||||
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
||||||
.text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")),
|
.text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")),
|
||||||
.text(entry.transcript)
|
.text(entry.transcript),
|
||||||
|
.text(entry.discussInTherapy ? "Ja" : "Nein"),
|
||||||
|
.text(entry.therapistNotes)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +480,9 @@ private struct WorkbookBuilder {
|
|||||||
.text(entry.socialContact ? "Ja" : "Nein"),
|
.text(entry.socialContact ? "Ja" : "Nein"),
|
||||||
.text(entry.musicInHead ? "Ja" : "Nein"),
|
.text(entry.musicInHead ? "Ja" : "Nein"),
|
||||||
.text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")),
|
.text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")),
|
||||||
.text(shortened(entry.transcript, limit: 90))
|
.text(shortened(entry.transcript, limit: 90)),
|
||||||
|
.text(entry.discussInTherapy ? "Ja" : "Nein"),
|
||||||
|
.text(shortened(entry.therapistNotes, limit: 90))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +493,8 @@ private struct WorkbookBuilder {
|
|||||||
.text(entry.musicInHead ? entry.earwormStrengthRaw : ""),
|
.text(entry.musicInHead ? entry.earwormStrengthRaw : ""),
|
||||||
.text(entry.musicInHead ? entry.distressRaw : ""),
|
.text(entry.musicInHead ? entry.distressRaw : ""),
|
||||||
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
||||||
.text(entry.transcript)
|
.text(entry.transcript),
|
||||||
|
.text(entry.therapistNotes)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,7 +821,7 @@ private enum SimpleZipArchive {
|
|||||||
archive.appendUInt32LE(centralOffset)
|
archive.appendUInt32LE(centralOffset)
|
||||||
archive.appendUInt16LE(0)
|
archive.appendUInt16LE(0)
|
||||||
|
|
||||||
try archive.write(to: url, options: .atomic)
|
try archive.write(to: url, options: [.atomic, .completeFileProtection])
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func crc32(_ data: Data) -> UInt32 {
|
private static func crc32(_ data: Data) -> UInt32 {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import AppIntents
|
|||||||
import Foundation
|
import Foundation
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
enum MomentRatingValue: String, AppEnum {
|
enum MomentRatingValue: String, AppEnum, Codable, Hashable, Sendable {
|
||||||
case veryGood = "Sehr gut"
|
case veryGood = "Sehr gut"
|
||||||
case good = "Gut"
|
case good = "Gut"
|
||||||
case middle = "Mittel"
|
case middle = "Mittel"
|
||||||
@@ -60,7 +60,16 @@ enum MomentWidgetStorage {
|
|||||||
static let lastRecordedAtKey = "lastWidgetSnapshotDate"
|
static let lastRecordedAtKey = "lastWidgetSnapshotDate"
|
||||||
|
|
||||||
static var defaults: UserDefaults {
|
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) {
|
static func enqueue(ratingRawValue: String) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ struct MomentEntryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 7) {
|
HStack(spacing: 7) {
|
||||||
ForEach(ratings, id: \.self) { rating in
|
ForEach(ratings, id: \.rawValue) { rating in
|
||||||
Button(intent: TrackMomentIntent(rating: rating)) {
|
Button(intent: TrackMomentIntent(rating: rating)) {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
Text(rating.emoji)
|
Text(rating.emoji)
|
||||||
|
|||||||
91
backend/README.md
Normal file
91
backend/README.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# FeelAloud Backend
|
||||||
|
|
||||||
|
Backend for real accounts:
|
||||||
|
|
||||||
|
- E-Mail and password registration/login
|
||||||
|
- Sign in with Apple ID-token verification
|
||||||
|
- Google ID-token verification
|
||||||
|
- JWT session tokens for the app
|
||||||
|
- Premium entitlement storage prepared for App Store Server verification
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create PostgreSQL database and enable UUID generation:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Copy config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Fill `.env`:
|
||||||
|
|
||||||
|
- `JWT_SECRET`: at least 32 random characters
|
||||||
|
- `APPLE_CLIENT_ID`: app bundle id, currently `de.feelaloud`
|
||||||
|
- `GOOGLE_CLIENT_IDS`: Google OAuth iOS/Web client IDs, comma-separated
|
||||||
|
|
||||||
|
5. Initialize DB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:init
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Start local API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `POST /auth/email/register`
|
||||||
|
- `POST /auth/email/login`
|
||||||
|
- `POST /auth/email/change-password`
|
||||||
|
- `POST /auth/apple`
|
||||||
|
- `POST /auth/google`
|
||||||
|
- `GET /me`
|
||||||
|
- `GET /premium/status`
|
||||||
|
- `POST /premium/app-store/transaction`
|
||||||
|
|
||||||
|
## iOS Configuration
|
||||||
|
|
||||||
|
In the app, set the backend URL in Settings -> Profil once that UI is wired, or set `authBackendURL` in `UserDefaults` during testing:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
UserDefaults.standard.set("http://localhost:8080", forKey: "authBackendURL")
|
||||||
|
```
|
||||||
|
|
||||||
|
For local device testing use your Mac's LAN IP instead of `localhost`.
|
||||||
|
|
||||||
|
## Required Apple/Google Console Setup
|
||||||
|
|
||||||
|
Apple:
|
||||||
|
|
||||||
|
- Paid Apple Developer account
|
||||||
|
- App capability: Sign in with Apple
|
||||||
|
- Bundle ID: `de.feelaloud`
|
||||||
|
- For server/web flows: Services ID and private key if you later exchange authorization codes server-side
|
||||||
|
|
||||||
|
Google:
|
||||||
|
|
||||||
|
- Google Cloud project
|
||||||
|
- OAuth consent screen
|
||||||
|
- iOS OAuth client for bundle ID `de.feelaloud`
|
||||||
|
- Add the resulting client ID to `GOOGLE_CLIENT_IDS`
|
||||||
|
|
||||||
|
Premium:
|
||||||
|
|
||||||
|
- Digital premium features must stay on StoreKit/In-App Purchase.
|
||||||
|
- PayPal is not the payment rail for iOS digital premium unlocks.
|
||||||
|
- Server-side transaction verification can be completed with App Store Server API once App Store Connect keys are available.
|
||||||
2103
backend/package-lock.json
generated
Normal file
2103
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
backend/package.json
Normal file
34
backend/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "feelaloud-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"db:init": "psql \"$DATABASE_URL\" -f schema.sql"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"google-auth-library": "^10.9.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"jose": "^5.9.6",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/schema.sql
Normal file
33
backend/schema.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create table if not exists users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
email text unique,
|
||||||
|
display_name text not null default '',
|
||||||
|
avatar_url text,
|
||||||
|
password_hash text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists identities (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
provider text not null check (provider in ('apple', 'google', 'email')),
|
||||||
|
provider_subject text not null,
|
||||||
|
email text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique(provider, provider_subject)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists premium_entitlements (
|
||||||
|
user_id uuid primary key references users(id) on delete cascade,
|
||||||
|
source text not null default 'app_store',
|
||||||
|
product_id text not null,
|
||||||
|
active boolean not null default false,
|
||||||
|
expires_at timestamptz,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists identities_user_id_idx on identities(user_id);
|
||||||
|
create index if not exists premium_entitlements_active_idx on premium_entitlements(active);
|
||||||
390
backend/src/server.ts
Normal file
390
backend/src/server.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import cors from 'cors';
|
||||||
|
import express from 'express';
|
||||||
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
port: Number(process.env.PORT ?? 8080),
|
||||||
|
databaseURL: required('DATABASE_URL'),
|
||||||
|
jwtSecret: required('JWT_SECRET'),
|
||||||
|
jwtExpiresIn: (process.env.JWT_EXPIRES_IN ?? '30d') as jwt.SignOptions['expiresIn'],
|
||||||
|
appleClientID: required('APPLE_CLIENT_ID'),
|
||||||
|
googleClientIDs: (process.env.GOOGLE_CLIENT_IDS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
corsOrigin: process.env.CORS_ORIGIN ?? '*'
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: config.databaseURL });
|
||||||
|
const googleClient = new OAuth2Client();
|
||||||
|
const appleJWKS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({ origin: config.corsOrigin === '*' ? true : config.corsOrigin }));
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
app.get('/health', (_request, response) => {
|
||||||
|
response.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/email/register', asyncHandler(async (request, response) => {
|
||||||
|
const input = emailAuthSchema.extend({
|
||||||
|
displayName: z.string().trim().max(120).optional()
|
||||||
|
}).parse(request.body);
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(input.password, 12);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const user = await createEmailUser(client, input.email, passwordHash, input.displayName ?? '');
|
||||||
|
await client.query('commit');
|
||||||
|
response.json(await sessionResponse(user.id));
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
response.status(409).json({ error: 'email_already_registered' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/auth/email/login', asyncHandler(async (request, response) => {
|
||||||
|
const input = emailAuthSchema.parse(request.body);
|
||||||
|
const { rows } = await pool.query<UserRow>(
|
||||||
|
'select * from users where lower(email) = lower($1) and password_hash is not null limit 1',
|
||||||
|
[input.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user || !user.password_hash || !(await bcrypt.compare(input.password, user.password_hash))) {
|
||||||
|
response.status(401).json({ error: 'invalid_credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json(await sessionResponse(user.id));
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/auth/email/change-password', requireAuth, asyncHandler(async (request, response) => {
|
||||||
|
const input = z.object({
|
||||||
|
currentPassword: z.string().min(8),
|
||||||
|
newPassword: z.string().min(10).max(200)
|
||||||
|
}).parse(request.body);
|
||||||
|
|
||||||
|
const { rows } = await pool.query<UserRow>('select * from users where id = $1 limit 1', [request.userID]);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user?.password_hash || !(await bcrypt.compare(input.currentPassword, user.password_hash))) {
|
||||||
|
response.status(401).json({ error: 'invalid_current_password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(input.newPassword, 12);
|
||||||
|
await pool.query(
|
||||||
|
'update users set password_hash = $1, updated_at = now() where id = $2',
|
||||||
|
[passwordHash, request.userID]
|
||||||
|
);
|
||||||
|
response.json({ ok: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/auth/apple', asyncHandler(async (request, response) => {
|
||||||
|
const input = appleAuthSchema.parse(request.body);
|
||||||
|
const payload = await verifyAppleIdentityToken(input.identityToken);
|
||||||
|
const subject = String(payload.sub ?? '');
|
||||||
|
if (!subject) {
|
||||||
|
response.status(401).json({ error: 'invalid_apple_subject' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = typeof payload.email === 'string' ? payload.email : input.email;
|
||||||
|
const user = await upsertOAuthUser({
|
||||||
|
provider: 'apple',
|
||||||
|
providerSubject: subject,
|
||||||
|
email,
|
||||||
|
displayName: input.displayName ?? ''
|
||||||
|
});
|
||||||
|
response.json(await sessionResponse(user.id));
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/auth/google', asyncHandler(async (request, response) => {
|
||||||
|
const input = googleAuthSchema.parse(request.body);
|
||||||
|
if (config.googleClientIDs.length === 0) {
|
||||||
|
response.status(503).json({ error: 'google_not_configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await googleClient.verifyIdToken({
|
||||||
|
idToken: input.idToken,
|
||||||
|
audience: config.googleClientIDs
|
||||||
|
});
|
||||||
|
const payload = ticket.getPayload();
|
||||||
|
const subject = payload?.sub;
|
||||||
|
if (!subject) {
|
||||||
|
response.status(401).json({ error: 'invalid_google_subject' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await upsertOAuthUser({
|
||||||
|
provider: 'google',
|
||||||
|
providerSubject: subject,
|
||||||
|
email: payload.email,
|
||||||
|
displayName: payload.name ?? ''
|
||||||
|
});
|
||||||
|
response.json(await sessionResponse(user.id));
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/me', requireAuth, asyncHandler(async (request, response) => {
|
||||||
|
response.json({ user: await publicUser(authenticatedUserID(request)) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/premium/status', requireAuth, asyncHandler(async (request, response) => {
|
||||||
|
const { rows } = await pool.query<PremiumRow>(
|
||||||
|
'select * from premium_entitlements where user_id = $1 limit 1',
|
||||||
|
[authenticatedUserID(request)]
|
||||||
|
);
|
||||||
|
response.json({ premium: entitlementIsActive(rows[0]), entitlement: rows[0] ?? null });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/premium/app-store/transaction', requireAuth, asyncHandler(async (_request, response) => {
|
||||||
|
response.status(501).json({
|
||||||
|
error: 'app_store_server_verification_not_configured',
|
||||||
|
message: 'StoreKit on-device verification is active in the app. Server-side App Store verification needs App Store Connect API keys and transaction JWS forwarding.'
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
response.status(400).json({ error: 'validation_failed', details: error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
response.status(500).json({ error: 'internal_server_error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`FeelAloud backend listening on :${config.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function required(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailAuthSchema = z.object({
|
||||||
|
email: z.string().trim().email().max(320),
|
||||||
|
password: z.string().min(8).max(200)
|
||||||
|
});
|
||||||
|
|
||||||
|
const appleAuthSchema = z.object({
|
||||||
|
identityToken: z.string().min(20),
|
||||||
|
authorizationCode: z.string().optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
displayName: z.string().trim().max(120).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleAuthSchema = z.object({
|
||||||
|
idToken: z.string().min(20)
|
||||||
|
});
|
||||||
|
|
||||||
|
async function verifyAppleIdentityToken(identityToken: string) {
|
||||||
|
const { payload } = await jwtVerify(identityToken, appleJWKS, {
|
||||||
|
issuer: 'https://appleid.apple.com',
|
||||||
|
audience: config.appleClientID
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEmailUser(client: pg.PoolClient, email: string, passwordHash: string, displayName: string): Promise<UserRow> {
|
||||||
|
const insertedUser = await client.query<UserRow>(
|
||||||
|
`insert into users (email, display_name, password_hash)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
returning *`,
|
||||||
|
[email, displayName, passwordHash]
|
||||||
|
);
|
||||||
|
const user = insertedUser.rows[0];
|
||||||
|
await client.query(
|
||||||
|
`insert into identities (user_id, provider, provider_subject, email)
|
||||||
|
values ($1, 'email', $2, $2)`,
|
||||||
|
[user.id, email]
|
||||||
|
);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertOAuthUser(input: OAuthUserInput): Promise<UserRow> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
|
||||||
|
const identity = await client.query<{ user_id: string }>(
|
||||||
|
'select user_id from identities where provider = $1 and provider_subject = $2 limit 1',
|
||||||
|
[input.provider, input.providerSubject]
|
||||||
|
);
|
||||||
|
if (identity.rows[0]) {
|
||||||
|
const existingUser = await client.query<UserRow>(
|
||||||
|
'select * from users where id = $1 limit 1',
|
||||||
|
[identity.rows[0].user_id]
|
||||||
|
);
|
||||||
|
const user = existingUser.rows[0];
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found for identity');
|
||||||
|
}
|
||||||
|
await client.query('commit');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingByEmail = input.email
|
||||||
|
? await client.query<UserRow>('select * from users where lower(email) = lower($1) limit 1', [input.email])
|
||||||
|
: { rows: [] as UserRow[] };
|
||||||
|
|
||||||
|
const user = existingByEmail.rows[0] ?? (await client.query<UserRow>(
|
||||||
|
`insert into users (email, display_name)
|
||||||
|
values ($1, $2)
|
||||||
|
returning *`,
|
||||||
|
[input.email ?? null, input.displayName]
|
||||||
|
)).rows[0];
|
||||||
|
|
||||||
|
if (input.displayName && !user.display_name) {
|
||||||
|
await client.query('update users set display_name = $1, updated_at = now() where id = $2', [input.displayName, user.id]);
|
||||||
|
user.display_name = input.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`insert into identities (user_id, provider, provider_subject, email)
|
||||||
|
values ($1, $2, $3, $4)`,
|
||||||
|
[user.id, input.provider, input.providerSubject, input.email ?? null]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('commit');
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessionResponse(userID: string) {
|
||||||
|
return {
|
||||||
|
token: jwt.sign({ sub: userID }, config.jwtSecret, { expiresIn: config.jwtExpiresIn }),
|
||||||
|
user: await publicUser(userID)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publicUser(userID: string): Promise<PublicUser> {
|
||||||
|
const { rows } = await pool.query<UserRow>('select * from users where id = $1 limit 1', [userID]);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.display_name,
|
||||||
|
avatarURL: user.avatar_url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitlementIsActive(row?: PremiumRow): boolean {
|
||||||
|
if (!row || !row.active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!row.expires_at) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return new Date(row.expires_at).getTime() > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(request: express.Request, response: express.Response, next: express.NextFunction) {
|
||||||
|
const header = request.header('authorization') ?? '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||||
|
if (!token) {
|
||||||
|
response.status(401).json({ error: 'missing_token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, config.jwtSecret);
|
||||||
|
if (typeof payload !== 'object' || typeof payload.sub !== 'string') {
|
||||||
|
response.status(401).json({ error: 'invalid_token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.userID = payload.sub;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
response.status(401).json({ error: 'invalid_token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticatedUserID(request: express.Request): string {
|
||||||
|
if (!request.userID) {
|
||||||
|
throw new Error('Authenticated route reached without user id');
|
||||||
|
}
|
||||||
|
return request.userID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asyncHandler(handler: express.RequestHandler): express.RequestHandler {
|
||||||
|
return (request, response, next) => {
|
||||||
|
Promise.resolve(handler(request, response, next)).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === '23505';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider = 'apple' | 'google' | 'email';
|
||||||
|
|
||||||
|
interface OAuthUserInput {
|
||||||
|
provider: Provider;
|
||||||
|
providerSubject: string;
|
||||||
|
email?: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
password_hash: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublicUser {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
displayName: string;
|
||||||
|
avatarURL: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PremiumRow {
|
||||||
|
user_id: string;
|
||||||
|
source: string;
|
||||||
|
product_id: string;
|
||||||
|
active: boolean;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
userID?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/tsconfig.json
Normal file
13
backend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user