Backup before friendly design pass
This commit is contained in:
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -169,13 +169,19 @@ final class SpeechRecognizer: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func requestMicrophonePermission() async -> Bool {
|
private func requestMicrophonePermission() async -> Bool {
|
||||||
switch AVAudioApplication.shared.recordPermission {
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||||
case .granted:
|
case .authorized:
|
||||||
true
|
true
|
||||||
case .denied:
|
case .denied:
|
||||||
false
|
false
|
||||||
case .undetermined:
|
case .restricted:
|
||||||
await AVAudioApplication.requestRecordPermission()
|
false
|
||||||
|
case .notDetermined:
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { isAllowed in
|
||||||
|
continuation.resume(returning: isAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@unknown default:
|
@unknown default:
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import AppIntents
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
struct StartDiaryEntryIntent: AppIntent, TargetContentProvidingIntent {
|
struct StartDiaryEntryIntent: AppIntent, TargetContentProvidingIntent {
|
||||||
static let title: LocalizedStringResource = "Eintrag aufnehmen"
|
static let title: LocalizedStringResource = "Eintrag aufnehmen"
|
||||||
@@ -13,6 +15,91 @@ struct StartDiaryEntryIntent: AppIntent, TargetContentProvidingIntent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum MomentRatingValue: String, AppEnum, Codable, Hashable, Sendable {
|
||||||
|
case veryGood = "Sehr gut"
|
||||||
|
case good = "Gut"
|
||||||
|
case middle = "Mittel"
|
||||||
|
case bad = "Schlecht"
|
||||||
|
case veryBad = "Sehr schlecht"
|
||||||
|
|
||||||
|
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Stimmung")
|
||||||
|
|
||||||
|
static let caseDisplayRepresentations: [MomentRatingValue: DisplayRepresentation] = [
|
||||||
|
.veryGood: "Sehr gut",
|
||||||
|
.good: "Gut",
|
||||||
|
.middle: "Mittel",
|
||||||
|
.bad: "Schlecht",
|
||||||
|
.veryBad: "Sehr schlecht"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrackMomentIntent: AppIntent {
|
||||||
|
static let title: LocalizedStringResource = "Momentaufnahme speichern"
|
||||||
|
static let description = IntentDescription("Speichert eine schnelle Stimmungsabfrage aus dem Homescreen-Widget.")
|
||||||
|
static let openAppWhenRun = true
|
||||||
|
|
||||||
|
@Parameter(title: "Stimmung")
|
||||||
|
var rating: MomentRatingValue
|
||||||
|
|
||||||
|
init() {
|
||||||
|
rating = .middle
|
||||||
|
}
|
||||||
|
|
||||||
|
init(rating: MomentRatingValue) {
|
||||||
|
self.rating = rating
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
AppMomentWidgetStorage.enqueue(ratingRawValue: rating.rawValue)
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "Moment")
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppMomentWidgetStorage {
|
||||||
|
static let appGroupIdentifier = "group.de.feelaloud"
|
||||||
|
static let pendingSnapshotsKey = "pendingWidgetSnapshots"
|
||||||
|
static let lastRatingKey = "lastWidgetSnapshotRating"
|
||||||
|
static let lastRecordedAtKey = "lastWidgetSnapshotDate"
|
||||||
|
|
||||||
|
static func enqueue(ratingRawValue: String) {
|
||||||
|
for defaults in writableDefaults {
|
||||||
|
var snapshots = pendingSnapshots(from: defaults)
|
||||||
|
snapshots.append(PendingWidgetSnapshot(ratingRawValue: ratingRawValue, createdAt: .now))
|
||||||
|
|
||||||
|
if let data = try? JSONEncoder().encode(snapshots) {
|
||||||
|
defaults.set(data, forKey: pendingSnapshotsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults.set(ratingRawValue, forKey: lastRatingKey)
|
||||||
|
defaults.set(Date.now, forKey: lastRecordedAtKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var writableDefaults: [UserDefaults] {
|
||||||
|
if FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) != nil,
|
||||||
|
let appGroupDefaults = UserDefaults(suiteName: appGroupIdentifier) {
|
||||||
|
return [appGroupDefaults, .standard]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.standard]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func pendingSnapshots(from defaults: UserDefaults) -> [PendingWidgetSnapshot] {
|
||||||
|
guard let data = defaults.data(forKey: pendingSnapshotsKey),
|
||||||
|
let snapshots = try? JSONDecoder().decode([PendingWidgetSnapshot].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PendingWidgetSnapshot: Codable {
|
||||||
|
var ratingRawValue: String
|
||||||
|
var createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
struct FeelAloudShortcuts: AppShortcutsProvider {
|
struct FeelAloudShortcuts: AppShortcutsProvider {
|
||||||
static let shortcutTileColor: ShortcutTileColor = .purple
|
static let shortcutTileColor: ShortcutTileColor = .purple
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ struct TherapySupportView: View {
|
|||||||
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var projectNotes: [TherapyProjectNote]
|
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var projectNotes: [TherapyProjectNote]
|
||||||
@Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var attachments: [TherapyProjectAttachment]
|
@Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var attachments: [TherapyProjectAttachment]
|
||||||
@Query(sort: \EmergencyPlan.updatedAt, order: .reverse) private var plans: [EmergencyPlan]
|
@Query(sort: \EmergencyPlan.updatedAt, order: .reverse) private var plans: [EmergencyPlan]
|
||||||
|
@Query(sort: \MoodEntry.createdAt, order: .reverse) private var diaryEntries: [MoodEntry]
|
||||||
|
@Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot]
|
||||||
|
|
||||||
@State private var selectedKind: TherapyItemKind?
|
@State private var selectedKind: TherapyItemKind?
|
||||||
@State private var editedItem: TherapyItem?
|
@State private var editedItem: TherapyItem?
|
||||||
@@ -19,6 +21,8 @@ struct TherapySupportView: View {
|
|||||||
@State private var showsNewProject = false
|
@State private var showsNewProject = false
|
||||||
@State private var showsPremiumPaywall = false
|
@State private var showsPremiumPaywall = false
|
||||||
@State private var infoKind: TherapyItemKind?
|
@State private var infoKind: TherapyItemKind?
|
||||||
|
@State private var showsEmergencyPlan = false
|
||||||
|
@State private var showsSessionMode = false
|
||||||
|
|
||||||
private var activeItems: [TherapyItem] {
|
private var activeItems: [TherapyItem] {
|
||||||
items.filter { item in
|
items.filter { item in
|
||||||
@@ -32,10 +36,6 @@ struct TherapySupportView: View {
|
|||||||
projects.filter { !$0.isArchived }
|
projects.filter { !$0.isArchived }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showsProjects: Bool {
|
|
||||||
selectedKind == nil || selectedKind == .project
|
|
||||||
}
|
|
||||||
|
|
||||||
private var showsItems: Bool {
|
private var showsItems: Bool {
|
||||||
selectedKind == nil || selectedKind == .goal || selectedKind == .task
|
selectedKind == nil || selectedKind == .goal || selectedKind == .task
|
||||||
}
|
}
|
||||||
@@ -48,88 +48,76 @@ struct TherapySupportView: View {
|
|||||||
items.filter { !$0.isArchived && $0.isCompleted && $0.kind != .project }.count
|
items.filter { !$0.isArchived && $0.isCompleted && $0.kind != .project }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var therapyEntries: [MoodEntry] {
|
||||||
|
diaryEntries.filter { $0.discussInTherapy }
|
||||||
|
}
|
||||||
|
|
||||||
private var plan: EmergencyPlan? {
|
private var plan: EmergencyPlan? {
|
||||||
plans.first
|
plans.first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sessionItems: [TherapyItem] {
|
||||||
|
items
|
||||||
|
.filter { !$0.isArchived && !$0.isCompleted && $0.kind != .project }
|
||||||
|
.sorted { first, second in
|
||||||
|
if first.kind == second.kind {
|
||||||
|
return first.createdAt > second.createdAt
|
||||||
|
}
|
||||||
|
return first.kind.rawValue < second.kind.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentSnapshots: [MoodSnapshot] {
|
||||||
|
let start = Calendar.current.date(byAdding: .day, value: -7, to: .now) ?? .distantPast
|
||||||
|
return snapshots.filter { $0.createdAt >= start }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section("Notfall") {
|
Section {
|
||||||
if let plan {
|
HStack(spacing: 10) {
|
||||||
NavigationLink {
|
Button {
|
||||||
EmergencyPlanView(plan: plan)
|
showsEmergencyPlan = true
|
||||||
} label: {
|
} label: {
|
||||||
SettingsLikeRow(
|
TherapyQuickAccessTile(
|
||||||
title: "Notfallplan",
|
title: "Notfallplan",
|
||||||
subtitle: emergencyPlanSubtitle(for: plan),
|
subtitle: plan.map(emergencyPlanSubtitle(for:)) ?? "Wird angelegt",
|
||||||
systemImage: "cross.case"
|
systemImage: "cross.case"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.buttonStyle(.plain)
|
||||||
}
|
.disabled(plan == nil)
|
||||||
|
|
||||||
Section("Therapiearbeit") {
|
Button {
|
||||||
therapyOverview
|
showsSessionMode = true
|
||||||
|
} label: {
|
||||||
Picker("Filter", selection: $selectedKind) {
|
TherapyQuickAccessTile(
|
||||||
Text("Alle").tag(nil as TherapyItemKind?)
|
title: "Sitzung",
|
||||||
ForEach([TherapyItemKind.goal, .task, .project]) { kind in
|
subtitle: "Kompakter Überblick",
|
||||||
Text(kind.rawValue).tag(kind as TherapyItemKind?)
|
systemImage: "rectangle.stack.badge.person.crop"
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
|
|
||||||
kindInfoRow
|
|
||||||
}
|
|
||||||
|
|
||||||
if showsProjects {
|
|
||||||
Section("Projektmappen") {
|
|
||||||
if premiumFeaturesEnabled {
|
|
||||||
if activeProjects.isEmpty {
|
|
||||||
ContentUnavailableView(
|
|
||||||
"Noch keine Projektmappe",
|
|
||||||
systemImage: "folder.badge.plus",
|
|
||||||
description: Text("Lege eine Mappe für ein Therapiethema an und sammle darin Notizen, Bilder, Dateien und Ordner.")
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
} else {
|
}
|
||||||
ForEach(activeProjects) { project in
|
|
||||||
|
Section("Projektordner") {
|
||||||
|
if premiumFeaturesEnabled {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
TherapyProjectDetailView(project: project)
|
TherapyProjectListView()
|
||||||
} label: {
|
} label: {
|
||||||
TherapyProjectRow(
|
SettingsLikeRow(
|
||||||
project: project,
|
title: "Projektordner",
|
||||||
noteCount: projectNotes.filter { $0.projectID == project.id }.count,
|
subtitle: "\(activeProjects.count) Projektordner",
|
||||||
attachmentCount: attachments.filter { $0.projectID == project.id }.count
|
systemImage: "folder"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
delete(project)
|
|
||||||
} label: {
|
|
||||||
Label("Löschen", systemImage: "trash")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
project.isArchived = true
|
|
||||||
try? modelContext.save()
|
|
||||||
} label: {
|
|
||||||
Label("Archiv", systemImage: "archivebox")
|
|
||||||
}
|
|
||||||
.tint(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showsNewProject = true
|
|
||||||
} label: {
|
|
||||||
Label("Projektmappe anlegen", systemImage: "folder.badge.plus")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
PremiumLockedRow(
|
PremiumLockedRow(
|
||||||
title: "Projektmappen freischalten",
|
title: "Projektordner freischalten",
|
||||||
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
|
subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.",
|
||||||
systemImage: "folder.badge.plus"
|
systemImage: "folder.badge.plus"
|
||||||
) {
|
) {
|
||||||
@@ -137,17 +125,37 @@ struct TherapySupportView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Therapiearbeit") {
|
||||||
|
therapyOverview
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
|
Picker("Filter", selection: $selectedKind) {
|
||||||
|
Text("Alle").tag(nil as TherapyItemKind?)
|
||||||
|
ForEach([TherapyItemKind.goal, .task]) { kind in
|
||||||
|
Text(kind.rawValue).tag(kind as TherapyItemKind?)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
|
kindInfoRow
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
if showsItems {
|
if showsItems {
|
||||||
Section("Ziele und Aufgaben") {
|
TherapySubsectionHeader(
|
||||||
if activeItems.isEmpty {
|
title: "Ziele und Aufgaben",
|
||||||
ContentUnavailableView(
|
subtitle: "Konkrete Arbeitspunkte aus Alltag und Sitzungen"
|
||||||
"Noch nichts angelegt",
|
|
||||||
systemImage: "target",
|
|
||||||
description: Text("Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.")
|
|
||||||
)
|
)
|
||||||
.listRowBackground(Color.clear)
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
|
if activeItems.isEmpty {
|
||||||
|
TherapyEmptyStateRow(
|
||||||
|
title: "Noch nichts angelegt",
|
||||||
|
subtitle: "Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.",
|
||||||
|
systemImage: "target"
|
||||||
|
)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
} else {
|
} else {
|
||||||
ForEach(activeItems) { item in
|
ForEach(activeItems) { item in
|
||||||
Button {
|
Button {
|
||||||
@@ -188,8 +196,33 @@ struct TherapySupportView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Besprechen") {
|
||||||
|
NavigationLink {
|
||||||
|
TherapyEntryListView(entries: therapyEntries)
|
||||||
|
} label: {
|
||||||
|
SettingsLikeRow(
|
||||||
|
title: "In Therapie besprechen",
|
||||||
|
subtitle: "\(therapyEntries.count) markierte Tagebucheinträge",
|
||||||
|
systemImage: "person.2.wave.2"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Therapie")
|
.navigationTitle("Therapie")
|
||||||
|
.navigationDestination(isPresented: $showsEmergencyPlan) {
|
||||||
|
if let plan {
|
||||||
|
EmergencyPlanView(plan: plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $showsSessionMode) {
|
||||||
|
TherapySessionView(
|
||||||
|
therapyEntries: therapyEntries,
|
||||||
|
openItems: sessionItems,
|
||||||
|
recentSnapshots: recentSnapshots,
|
||||||
|
plan: plan
|
||||||
|
)
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
@@ -235,7 +268,7 @@ struct TherapySupportView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showsPremiumPaywall) {
|
.sheet(isPresented: $showsPremiumPaywall) {
|
||||||
PremiumPaywallView(
|
PremiumPaywallView(
|
||||||
title: "Projektmappen freischalten",
|
title: "Projektordner freischalten",
|
||||||
message: "Projektordner sind eine Bonusfunktion für Unterstützer:innen. Ziele, Aufgaben und der Notfallplan bleiben frei nutzbar."
|
message: "Projektordner sind eine Bonusfunktion für Unterstützer:innen. Ziele, Aufgaben und der Notfallplan bleiben frei nutzbar."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -247,14 +280,9 @@ struct TherapySupportView: View {
|
|||||||
|
|
||||||
private var therapyOverview: some View {
|
private var therapyOverview: some View {
|
||||||
LazyVGrid(
|
LazyVGrid(
|
||||||
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
|
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
|
||||||
spacing: 8
|
spacing: 8
|
||||||
) {
|
) {
|
||||||
TherapyMetricView(
|
|
||||||
title: "Mappen",
|
|
||||||
value: premiumFeaturesEnabled ? "\(activeProjects.count)" : "Bonus",
|
|
||||||
systemImage: premiumFeaturesEnabled ? "folder" : "lock"
|
|
||||||
)
|
|
||||||
TherapyMetricView(title: "Offen", value: "\(openItemCount)", systemImage: "circle")
|
TherapyMetricView(title: "Offen", value: "\(openItemCount)", systemImage: "circle")
|
||||||
TherapyMetricView(title: "Erledigt", value: "\(completedItemCount)", systemImage: "checkmark.circle.fill")
|
TherapyMetricView(title: "Erledigt", value: "\(completedItemCount)", systemImage: "checkmark.circle.fill")
|
||||||
}
|
}
|
||||||
@@ -263,14 +291,6 @@ struct TherapySupportView: View {
|
|||||||
|
|
||||||
private var kindInfoRow: some View {
|
private var kindInfoRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button {
|
|
||||||
infoKind = .project
|
|
||||||
} label: {
|
|
||||||
Label("Projektmappe", systemImage: "questionmark.circle")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
|
|
||||||
ForEach([TherapyItemKind.goal, .task]) { kind in
|
ForEach([TherapyItemKind.goal, .task]) { kind in
|
||||||
Button {
|
Button {
|
||||||
infoKind = kind
|
infoKind = kind
|
||||||
@@ -359,6 +379,282 @@ private struct PremiumLockedRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct TherapyProjectListView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Query(sort: \TherapyProject.createdAt, order: .reverse) private var projects: [TherapyProject]
|
||||||
|
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var projectNotes: [TherapyProjectNote]
|
||||||
|
@Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var attachments: [TherapyProjectAttachment]
|
||||||
|
|
||||||
|
@State private var showsNewProject = false
|
||||||
|
|
||||||
|
private var activeProjects: [TherapyProject] {
|
||||||
|
projects.filter { !$0.isArchived }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Projektordner") {
|
||||||
|
if activeProjects.isEmpty {
|
||||||
|
TherapyEmptyStateRow(
|
||||||
|
title: "Noch keine Projektmappe",
|
||||||
|
subtitle: "Lege eine Mappe für ein Therapiethema an und sammle darin Notizen, Bilder, Dateien und Ordner.",
|
||||||
|
systemImage: "folder.badge.plus"
|
||||||
|
)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
} else {
|
||||||
|
ForEach(activeProjects) { project in
|
||||||
|
NavigationLink {
|
||||||
|
TherapyProjectDetailView(project: project)
|
||||||
|
} label: {
|
||||||
|
TherapyProjectRow(
|
||||||
|
project: project,
|
||||||
|
noteCount: projectNotes.filter { $0.projectID == project.id }.count,
|
||||||
|
attachmentCount: attachments.filter { $0.projectID == project.id }.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
delete(project)
|
||||||
|
} label: {
|
||||||
|
Label("Löschen", systemImage: "trash")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
project.isArchived = true
|
||||||
|
try? modelContext.save()
|
||||||
|
} label: {
|
||||||
|
Label("Archiv", systemImage: "archivebox")
|
||||||
|
}
|
||||||
|
.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showsNewProject = true
|
||||||
|
} label: {
|
||||||
|
Label("Projektmappe anlegen", systemImage: "folder.badge.plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Projektordner")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(isPresented: $showsNewProject) {
|
||||||
|
EditTherapyProjectView(project: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func delete(_ project: TherapyProject) {
|
||||||
|
let projectAttachments = attachments.filter { $0.projectID == project.id }
|
||||||
|
for attachment in projectAttachments {
|
||||||
|
try? TherapyProjectFileStore.delete(attachment)
|
||||||
|
modelContext.delete(attachment)
|
||||||
|
}
|
||||||
|
projectNotes
|
||||||
|
.filter { $0.projectID == project.id }
|
||||||
|
.forEach(modelContext.delete)
|
||||||
|
modelContext.delete(project)
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TherapySessionView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.appColorProfile) private var colorProfile
|
||||||
|
|
||||||
|
let therapyEntries: [MoodEntry]
|
||||||
|
let openItems: [TherapyItem]
|
||||||
|
let recentSnapshots: [MoodSnapshot]
|
||||||
|
let plan: EmergencyPlan?
|
||||||
|
|
||||||
|
private var recentAverage: Double? {
|
||||||
|
guard !recentSnapshots.isEmpty else { return nil }
|
||||||
|
return recentSnapshots.map(\.rating.score).reduce(0, +) / Double(recentSnapshots.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentAverageLabel: String {
|
||||||
|
guard let recentAverage else { return "Noch keine Checks" }
|
||||||
|
return SnapshotRating.label(for: recentAverage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentDifficultCount: Int {
|
||||||
|
recentSnapshots.filter { $0.rating.score <= 2 }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Überblick") {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
sessionMetric(
|
||||||
|
value: "\(therapyEntries.count)",
|
||||||
|
label: "Einträge",
|
||||||
|
systemImage: "person.2.wave.2"
|
||||||
|
)
|
||||||
|
sessionMetric(
|
||||||
|
value: "\(openItems.count)",
|
||||||
|
label: "Offen",
|
||||||
|
systemImage: "checklist"
|
||||||
|
)
|
||||||
|
sessionMetric(
|
||||||
|
value: recentAverageLabel,
|
||||||
|
label: "7 Tage",
|
||||||
|
systemImage: "chart.line.uptrend.xyaxis"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("In Therapie besprechen") {
|
||||||
|
if therapyEntries.isEmpty {
|
||||||
|
TherapyEmptyStateRow(
|
||||||
|
title: "Keine markierten Einträge",
|
||||||
|
subtitle: "Markiere Tagebucheinträge im Verlauf, damit sie hier für die Sitzung bereitliegen.",
|
||||||
|
systemImage: "person.2.wave.2"
|
||||||
|
)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
} else {
|
||||||
|
ForEach(therapyEntries.prefix(5)) { entry in
|
||||||
|
NavigationLink {
|
||||||
|
EntryDetailView(entry: entry)
|
||||||
|
} label: {
|
||||||
|
SessionEntryRow(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if therapyEntries.count > 5 {
|
||||||
|
NavigationLink {
|
||||||
|
TherapyEntryListView(entries: therapyEntries)
|
||||||
|
} label: {
|
||||||
|
Label("Alle \(therapyEntries.count) markierten Einträge anzeigen", systemImage: "list.bullet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Offene Ziele und Aufgaben") {
|
||||||
|
if openItems.isEmpty {
|
||||||
|
TherapyEmptyStateRow(
|
||||||
|
title: "Keine offenen Punkte",
|
||||||
|
subtitle: "Offene Ziele und Aufgaben erscheinen hier automatisch.",
|
||||||
|
systemImage: "checkmark.circle"
|
||||||
|
)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
} else {
|
||||||
|
ForEach(openItems.prefix(6)) { item in
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: item.kind.systemImage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(colorProfile.accent)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(colorProfile.accent.opacity(0.1), in: Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(item.title.isEmpty ? item.kind.rawValue : item.title)
|
||||||
|
.font(.headline)
|
||||||
|
if !item.notes.isEmpty {
|
||||||
|
Text(item.notes)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
if let dueDate = item.dueDate {
|
||||||
|
Text("Fällig: \(dueDate.formatted(date: .abbreviated, time: .omitted))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
item.isCompleted = true
|
||||||
|
try? modelContext.save()
|
||||||
|
} label: {
|
||||||
|
Label("Erledigt", systemImage: "checkmark.circle.fill")
|
||||||
|
}
|
||||||
|
.tint(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Stimmung") {
|
||||||
|
LabeledContent("Durchschnitt letzte 7 Tage", value: recentAverageLabel)
|
||||||
|
LabeledContent("Schwierige Checks", value: "\(recentDifficultCount)")
|
||||||
|
if recentDifficultCount > 0 {
|
||||||
|
Text("Nutze diese Zahl als Gesprächsanlass, nicht als Bewertung.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let plan {
|
||||||
|
Section("Notfallplan") {
|
||||||
|
NavigationLink {
|
||||||
|
EmergencyPlanView(plan: plan)
|
||||||
|
} label: {
|
||||||
|
SettingsLikeRow(
|
||||||
|
title: "Notfallplan öffnen",
|
||||||
|
subtitle: "Akut hilfreich, falls das Gespräch belastend wird",
|
||||||
|
systemImage: "cross.case"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Sitzung")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sessionMetric(value: String, label: String, systemImage: String) -> some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.foregroundStyle(colorProfile.accent)
|
||||||
|
Text(value)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.72)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 74)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(colorProfile.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SessionEntryRow: View {
|
||||||
|
let entry: MoodEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: entry.mood.symbol)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(entry.mood.color)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(entry.mood.color.opacity(0.12), in: Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(entry.mood.rawValue)
|
||||||
|
.font(.headline)
|
||||||
|
Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if !entry.transcript.isEmpty {
|
||||||
|
Text(entry.transcript)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct TherapyProjectDetailView: View {
|
private struct TherapyProjectDetailView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var allNotes: [TherapyProjectNote]
|
@Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var allNotes: [TherapyProjectNote]
|
||||||
@@ -816,6 +1112,99 @@ private struct SettingsLikeRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct TherapyQuickAccessTile: View {
|
||||||
|
@Environment(\.appColorProfile) private var colorProfile
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let systemImage: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(colorProfile.accent)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.background(colorProfile.accent.opacity(0.12), in: Circle())
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.75))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.82)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 98, alignment: .topLeading)
|
||||||
|
.padding(11)
|
||||||
|
.background(colorProfile.accent.opacity(0.055), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(colorProfile.accent.opacity(0.12), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TherapySubsectionHeader: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary.opacity(0.82))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TherapyEmptyStateRow: View {
|
||||||
|
@Environment(\.appColorProfile) private var colorProfile
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let systemImage: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(colorProfile.accent.opacity(0.8))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(colorProfile.accent.opacity(0.07), in: Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct EmergencyPlanView: View {
|
private struct EmergencyPlanView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Bindable var plan: EmergencyPlan
|
@Bindable var plan: EmergencyPlan
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user