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>
220 lines
6.7 KiB
Swift
220 lines
6.7 KiB
Swift
//
|
|
// 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())
|
|
}
|
|
}
|