Add AI-powered mental wellness features: Reflection Companion, Pattern Tags, Weekly Digest

Three new Foundation Models features to deepen user engagement with mental wellness:

1. AI Reflection Companion — personalized feedback after completing guided reflections,
   referencing the user's actual words with personality-pack-adapted tone
2. Mood Pattern Tags — auto-extracts theme tags (work, family, stress, etc.) from notes
   and reflections, displayed as colored pills on entries
3. Weekly Emotional Digest — BGTask-scheduled Sunday digest with headline, summary,
   highlight, and intention; shown as card in Insights tab with notification

All features: on-device (zero cost), premium-gated, iOS 26+ with graceful degradation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-04 00:47:28 -05:00
parent 43ff239781
commit ab8d8fbdc0
18 changed files with 1076 additions and 3 deletions

View File

@@ -0,0 +1,219 @@
//
// ReflectionFeedbackView.swift
// Reflect
//
// Displays AI-generated personalized feedback after completing a guided reflection.
//
import SwiftUI
struct ReflectionFeedbackView: View {
let mood: Mood
let reflection: GuidedReflection
let onDismiss: () -> Void
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@State private var feedback: ReflectionFeedbackState = .loading
@State private var appeared = false
private var accentColor: Color { moodTint.color(forMood: mood) }
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View {
VStack(spacing: 24) {
headerIcon
switch feedback {
case .loading:
loadingContent
case .loaded(let affirmation, let observation, let takeaway, let iconName):
feedbackContent(affirmation: affirmation, observation: observation, takeaway: takeaway, iconName: iconName)
case .error:
fallbackContent
case .unavailable:
fallbackContent
}
dismissButton
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(Color(.secondarySystemBackground))
)
.padding(.horizontal, 20)
.opacity(appeared ? 1 : 0)
.scaleEffect(appeared ? 1 : 0.95)
.task {
await generateFeedback()
}
.onAppear {
withAnimation(.easeOut(duration: 0.3)) {
appeared = true
}
}
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.container)
}
// MARK: - Header
private var headerIcon: some View {
VStack(spacing: 12) {
Image(systemName: "sparkles")
.font(.system(size: 32))
.foregroundStyle(accentColor)
.symbolEffect(.pulse, options: .repeating, isActive: feedback.isLoading)
Text(String(localized: "Your Reflection"))
.font(.headline)
.foregroundColor(textColor)
}
}
// MARK: - Loading
private var loadingContent: some View {
VStack(spacing: 16) {
ForEach(0..<3, id: \.self) { _ in
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(height: 16)
.shimmering()
}
}
.padding(.vertical, 8)
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.loading)
}
// MARK: - Feedback Content
private func feedbackContent(affirmation: String, observation: String, takeaway: String, iconName: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
feedbackRow(icon: iconName, text: affirmation)
feedbackRow(icon: "eye.fill", text: observation)
feedbackRow(icon: "arrow.right.circle.fill", text: takeaway)
}
.transition(.opacity.combined(with: .move(edge: .bottom)))
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.content)
}
private func feedbackRow(icon: String, text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.body)
.foregroundStyle(accentColor)
.frame(width: 24, height: 24)
Text(text)
.font(.subheadline)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Fallback (no AI available)
private var fallbackContent: some View {
VStack(spacing: 8) {
Text(String(localized: "Great job completing your reflection. Taking time to check in with yourself is a powerful habit."))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.fallback)
}
// MARK: - Dismiss
private var dismissButton: some View {
Button {
onDismiss()
} label: {
Text(String(localized: "Done"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(accentColor)
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.doneButton)
}
// MARK: - Generation
private func generateFeedback() async {
// Check premium access
guard !IAPManager.shared.shouldShowPaywall else {
feedback = .unavailable
return
}
if #available(iOS 26, *) {
let service = FoundationModelsReflectionService()
do {
let result = try await service.generateFeedback(for: reflection, mood: mood)
withAnimation(.easeInOut(duration: 0.3)) {
feedback = .loaded(
affirmation: result.affirmation,
observation: result.observation,
takeaway: result.takeaway,
iconName: result.iconName
)
}
} catch {
withAnimation {
feedback = .error
}
}
} else {
feedback = .unavailable
}
}
}
// MARK: - State
private enum ReflectionFeedbackState {
case loading
case loaded(affirmation: String, observation: String, takeaway: String, iconName: String)
case error
case unavailable
var isLoading: Bool {
if case .loading = self { return true }
return false
}
}
// MARK: - Shimmer Effect
private struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
colors: [.clear, Color.white.opacity(0.3), .clear],
startPoint: .leading,
endPoint: .trailing
)
.offset(x: phase)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 300
}
}
)
.mask(content)
}
}
private extension View {
func shimmering() -> some View {
modifier(ShimmerModifier())
}
}