Backup before friendly design pass

This commit is contained in:
MrDiderot
2026-07-23 10:33:12 +02:00
parent 39a0e35fd2
commit af1e67f4cc
8 changed files with 957 additions and 130 deletions

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]

View File

@@ -18,6 +18,15 @@ 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 {
@@ -25,6 +34,9 @@ struct HomeView: View {
appHeader appHeader
welcomeCard welcomeCard
snapshotButton snapshotButton
if showsSafetySupportCard {
safetySupportCard
}
weekOverview weekOverview
if let latest = entries.first { if let latest = entries.first {
@@ -86,6 +98,29 @@ struct HomeView: View {
.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(.background, in: RoundedRectangle(cornerRadius: 18))
}
private var welcomeCard: some View { private var welcomeCard: some View {
Button { Button {
router.presentNewEntry(startRecording: false) router.presentNewEntry(startRecording: false)

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 {

View File

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

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

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