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:
@@ -24,6 +24,8 @@ struct GuidedReflectionView: View {
|
||||
@State private var isSaving = false
|
||||
@State private var showDiscardAlert = false
|
||||
@State private var showInfoSheet = false
|
||||
@State private var showFeedback = false
|
||||
@State private var savedReflection: GuidedReflection?
|
||||
|
||||
private let initialDraft: GuidedReflectionDraft
|
||||
|
||||
@@ -77,8 +79,24 @@ struct GuidedReflectionView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollViewReader { proxy in
|
||||
reflectionSheetContent(with: proxy)
|
||||
ZStack {
|
||||
ScrollViewReader { proxy in
|
||||
reflectionSheetContent(with: proxy)
|
||||
}
|
||||
.blur(radius: showFeedback ? 6 : 0)
|
||||
.allowsHitTesting(!showFeedback)
|
||||
|
||||
if showFeedback, let savedReflection {
|
||||
Color.black.opacity(0.3)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { }
|
||||
|
||||
ReflectionFeedbackView(
|
||||
mood: entry.mood,
|
||||
reflection: savedReflection,
|
||||
onDismiss: { dismiss() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,7 +472,22 @@ struct GuidedReflectionView: View {
|
||||
)
|
||||
|
||||
if success {
|
||||
dismiss()
|
||||
// Fire-and-forget tag extraction
|
||||
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall {
|
||||
Task {
|
||||
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Show AI feedback if reflection is complete and AI is potentially available
|
||||
if reflection.isComplete {
|
||||
savedReflection = reflection
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
showFeedback = true
|
||||
}
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
isSaving = false
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ struct InsightsView: View {
|
||||
@EnvironmentObject var iapManager: IAPManager
|
||||
@State private var showSubscriptionStore = false
|
||||
@State private var selectedTab: InsightsTab = .insights
|
||||
@State private var weeklyDigest: WeeklyDigest?
|
||||
@State private var showDigest = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -94,15 +96,34 @@ struct InsightsView: View {
|
||||
.onAppear {
|
||||
AnalyticsManager.shared.trackScreen(.insights)
|
||||
viewModel.generateInsights()
|
||||
loadWeeklyDigest()
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
// MARK: - Insights Content
|
||||
|
||||
private func loadWeeklyDigest() {
|
||||
if #available(iOS 26, *), !iapManager.shouldShowPaywall {
|
||||
if let digest = FoundationModelsDigestService.shared.loadLatestDigest(),
|
||||
digest.isFromCurrentWeek,
|
||||
!WeeklyDigest.isDismissed(for: digest) {
|
||||
weeklyDigest = digest
|
||||
showDigest = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var insightsContent: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Weekly Digest Card
|
||||
if showDigest, let digest = weeklyDigest {
|
||||
WeeklyDigestCardView(digest: digest) {
|
||||
showDigest = false
|
||||
}
|
||||
}
|
||||
|
||||
// This Month Section
|
||||
InsightsSectionView(
|
||||
title: "This Month",
|
||||
|
||||
130
Shared/Views/InsightsView/WeeklyDigestCardView.swift
Normal file
130
Shared/Views/InsightsView/WeeklyDigestCardView.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// WeeklyDigestCardView.swift
|
||||
// Reflect
|
||||
//
|
||||
// Displays the AI-generated weekly emotional digest card in the Insights tab.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WeeklyDigestCardView: View {
|
||||
|
||||
let digest: WeeklyDigest
|
||||
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
|
||||
|
||||
private var textColor: Color { theme.currentTheme.labelColor }
|
||||
private var accentColor: Color { moodTint.color(forMood: .good) }
|
||||
|
||||
@State private var appeared = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
HStack {
|
||||
Image(systemName: digest.iconName)
|
||||
.font(.title2)
|
||||
.foregroundStyle(accentColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(localized: "Weekly Digest"))
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
Text(digest.headline)
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
WeeklyDigest.markDismissed()
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
onDismiss()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.accessibilityLabel(String(localized: "Dismiss digest"))
|
||||
.accessibilityIdentifier(AccessibilityID.WeeklyDigest.dismissButton)
|
||||
}
|
||||
|
||||
// Summary
|
||||
Text(digest.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(textColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Divider()
|
||||
|
||||
// Highlight
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.yellow)
|
||||
.padding(.top, 2)
|
||||
|
||||
Text(digest.highlight)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(textColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Intention
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "arrow.right.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(accentColor)
|
||||
.padding(.top, 2)
|
||||
|
||||
Text(digest.intention)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Date range
|
||||
Text(dateRangeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [accentColor.opacity(0.3), .purple.opacity(0.2)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 10)
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.4)) {
|
||||
appeared = true
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.WeeklyDigest.card)
|
||||
}
|
||||
|
||||
private var dateRangeString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
return "\(formatter.string(from: digest.weekStartDate)) - \(formatter.string(from: digest.weekEndDate))"
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,12 @@ struct NoteEditorView: View {
|
||||
let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave)
|
||||
|
||||
if success {
|
||||
// Fire-and-forget tag extraction after saving a note
|
||||
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall, noteToSave != nil {
|
||||
Task {
|
||||
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
|
||||
}
|
||||
}
|
||||
dismiss()
|
||||
} else {
|
||||
isSaving = false
|
||||
@@ -186,6 +192,11 @@ struct EntryDetailView: View {
|
||||
// Mood section
|
||||
moodSection
|
||||
|
||||
// Tags section
|
||||
if entry.hasTags {
|
||||
tagsSection
|
||||
}
|
||||
|
||||
// Guided reflection section
|
||||
if currentMood != .missing && currentMood != .placeholder {
|
||||
reflectionSection
|
||||
@@ -389,6 +400,35 @@ struct EntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var tagsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(String(localized: "Themes"))
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
FlowLayout(spacing: 8) {
|
||||
ForEach(entry.tags, id: \.self) { tag in
|
||||
Text(tag.capitalized)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(moodColor.opacity(0.15))
|
||||
)
|
||||
.foregroundColor(moodColor)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var notesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
|
||||
219
Shared/Views/ReflectionFeedbackView.swift
Normal file
219
Shared/Views/ReflectionFeedbackView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user