Compare commits

...

4 Commits

Author SHA1 Message Date
MrDiderot
a781a3561f Refine onboarding account and premium flows 2026-07-23 15:12:05 +02:00
MrDiderot
af1e67f4cc Backup before friendly design pass 2026-07-23 10:33:12 +02:00
MrDiderot
39a0e35fd2 Add OAuth backend scaffold 2026-07-22 22:54:03 +02:00
MrDiderot
77d93c4f8d Add account sign-in structure 2026-07-22 22:27:47 +02:00
25 changed files with 5288 additions and 283 deletions

5
.gitignore vendored
View File

@@ -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

View File

@@ -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 */,

View 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?
}

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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))

View File

@@ -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,20 +1060,16 @@ 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",
isOn: therapyDiscussionBinding
)
}
Button { if entry.discussInTherapy {
entry.discussInTherapy.toggle() Section("Zusatznotizen für den Therapeuten") {
try? modelContext.save() TextEditor(text: therapistNotesBinding)
} label: { .frame(minHeight: 100)
Label(
entry.discussInTherapy
? "Nicht mehr in Therapie besprechen"
: "In Therapie besprechen",
systemImage: entry.discussInTherapy
? "person.2.wave.2.fill"
: "person.2.wave.2"
)
} }
} }
@@ -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()

View File

@@ -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 }

View File

@@ -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: " ")
}
} }

View File

@@ -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)
} }

View File

@@ -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

View File

@@ -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)"
}
} }
} }

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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,106 +48,114 @@ 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)
Button {
showsSessionMode = true
} label: {
TherapyQuickAccessTile(
title: "Sitzung",
subtitle: "Kompakter Überblick",
systemImage: "rectangle.stack.badge.person.crop"
)
}
.buttonStyle(.plain)
}
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
.listRowBackground(Color.clear)
}
Section("Projektordner") {
if premiumFeaturesEnabled {
NavigationLink {
TherapyProjectListView()
} label: {
SettingsLikeRow(
title: "Projektordner",
subtitle: "\(activeProjects.count) Projektordner",
systemImage: "folder"
)
}
} else {
PremiumLockedRow(
title: "Projektordner freischalten",
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
systemImage: "folder.badge.plus"
) {
showsPremiumPaywall = true
}
} }
} }
Section("Therapiearbeit") { Section("Therapiearbeit") {
therapyOverview therapyOverview
.listRowSeparator(.hidden)
Picker("Filter", selection: $selectedKind) { Picker("Filter", selection: $selectedKind) {
Text("Alle").tag(nil as TherapyItemKind?) Text("Alle").tag(nil as TherapyItemKind?)
ForEach([TherapyItemKind.goal, .task, .project]) { kind in ForEach([TherapyItemKind.goal, .task]) { kind in
Text(kind.rawValue).tag(kind as TherapyItemKind?) Text(kind.rawValue).tag(kind as TherapyItemKind?)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.listRowSeparator(.hidden)
kindInfoRow kindInfoRow
} .listRowSeparator(.hidden)
if showsProjects { if showsItems {
Section("Projektmappen") { TherapySubsectionHeader(
if premiumFeaturesEnabled { title: "Ziele und Aufgaben",
if activeProjects.isEmpty { subtitle: "Konkrete Arbeitspunkte aus Alltag und Sitzungen"
ContentUnavailableView( )
"Noch keine Projektmappe", .listRowSeparator(.hidden)
systemImage: "folder.badge.plus",
description: Text("Lege eine Mappe für ein Therapiethema an und sammle darin Notizen, Bilder, Dateien und Ordner.")
)
.listRowBackground(Color.clear)
} else {
ForEach(activeProjects) { project in
NavigationLink {
TherapyProjectDetailView(project: project)
} label: {
TherapyProjectRow(
project: project,
noteCount: projectNotes.filter { $0.projectID == project.id }.count,
attachmentCount: attachments.filter { $0.projectID == project.id }.count
)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
delete(project)
} label: {
Label("Löschen", systemImage: "trash")
}
Button {
project.isArchived = true
try? modelContext.save()
} label: {
Label("Archiv", systemImage: "archivebox")
}
.tint(.gray)
}
}
}
Button {
showsNewProject = true
} label: {
Label("Projektmappe anlegen", systemImage: "folder.badge.plus")
}
} else {
PremiumLockedRow(
title: "Projektmappen freischalten",
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
systemImage: "folder.badge.plus"
) {
showsPremiumPaywall = true
}
}
}
}
if showsItems {
Section("Ziele und Aufgaben") {
if activeItems.isEmpty { if activeItems.isEmpty {
ContentUnavailableView( TherapyEmptyStateRow(
"Noch nichts angelegt", title: "Noch nichts angelegt",
systemImage: "target", subtitle: "Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.",
description: Text("Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.") systemImage: "target"
) )
.listRowBackground(Color.clear) .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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

34
backend/package.json Normal file
View 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
View 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
View 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
View 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"]
}