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:
@@ -6,6 +6,7 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>com.88oakapps.reflect.dbUpdateMissing</string>
|
<string>com.88oakapps.reflect.dbUpdateMissing</string>
|
||||||
<string>com.88oakapps.reflect.weatherRetry</string>
|
<string>com.88oakapps.reflect.weatherRetry</string>
|
||||||
|
<string>com.88oakapps.reflect.weeklyDigest</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Reflect uses your location to show weather details for your mood entries.</string>
|
<string>Reflect uses your location to show weather details for your mood entries.</string>
|
||||||
|
|||||||
@@ -97,6 +97,21 @@ enum AccessibilityID {
|
|||||||
static let baLearnMoreLink = "guided_reflection_ba_learn_more"
|
static let baLearnMoreLink = "guided_reflection_ba_learn_more"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Reflection Feedback
|
||||||
|
enum ReflectionFeedback {
|
||||||
|
static let container = "reflection_feedback_container"
|
||||||
|
static let loading = "reflection_feedback_loading"
|
||||||
|
static let content = "reflection_feedback_content"
|
||||||
|
static let fallback = "reflection_feedback_fallback"
|
||||||
|
static let doneButton = "reflection_feedback_done"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Weekly Digest
|
||||||
|
enum WeeklyDigest {
|
||||||
|
static let card = "weekly_digest_card"
|
||||||
|
static let dismissButton = "weekly_digest_dismiss"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
enum Settings {
|
enum Settings {
|
||||||
static let header = "settings_header"
|
static let header = "settings_header"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import BackgroundTasks
|
|||||||
class BGTask {
|
class BGTask {
|
||||||
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
|
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
|
||||||
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
|
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
|
||||||
|
static let weeklyDigestID = "com.88oakapps.reflect.weeklyDigest"
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class func runFillInMissingDatesTask(task: BGProcessingTask) {
|
class func runFillInMissingDatesTask(task: BGProcessingTask) {
|
||||||
@@ -55,6 +56,65 @@ class BGTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class func runWeeklyDigestTask(task: BGProcessingTask) {
|
||||||
|
BGTask.scheduleWeeklyDigest()
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
Task {
|
||||||
|
guard !IAPManager.shared.shouldShowPaywall else {
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest()
|
||||||
|
|
||||||
|
// Send local notification with the headline
|
||||||
|
let personalityPack = UserDefaultsStore.personalityPackable()
|
||||||
|
LocalNotification.scheduleDigestNotification(headline: digest.headline, personalityPack: personalityPack)
|
||||||
|
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
} catch {
|
||||||
|
print("Weekly digest generation failed: \(error)")
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class func scheduleWeeklyDigest() {
|
||||||
|
let request = BGProcessingTaskRequest(identifier: BGTask.weeklyDigestID)
|
||||||
|
request.requiresNetworkConnectivity = false
|
||||||
|
request.requiresExternalPower = false
|
||||||
|
|
||||||
|
// Schedule for next Sunday at 7 PM
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())
|
||||||
|
components.weekday = 1 // Sunday
|
||||||
|
components.hour = 19
|
||||||
|
components.minute = 0
|
||||||
|
|
||||||
|
var nextSunday = calendar.date(from: components) ?? Date()
|
||||||
|
if nextSunday <= Date() {
|
||||||
|
nextSunday = calendar.date(byAdding: .weekOfYear, value: 1, to: nextSunday)!
|
||||||
|
}
|
||||||
|
|
||||||
|
request.earliestBeginDate = nextSunday
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Could not schedule weekly digest: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class func scheduleBackgroundProcessing() {
|
class func scheduleBackgroundProcessing() {
|
||||||
let request = BGProcessingTaskRequest(identifier: BGTask.updateDBMissingID)
|
let request = BGProcessingTaskRequest(identifier: BGTask.updateDBMissingID)
|
||||||
request.requiresNetworkConnectivity = false
|
request.requiresNetworkConnectivity = false
|
||||||
|
|||||||
@@ -135,6 +135,28 @@ class LocalNotification {
|
|||||||
|
|
||||||
// MARK: - Debug: Send All Personality Pack Notifications
|
// MARK: - Debug: Send All Personality Pack Notifications
|
||||||
|
|
||||||
|
// MARK: - Weekly Digest Notification
|
||||||
|
|
||||||
|
public class func scheduleDigestNotification(headline: String, personalityPack: PersonalityPack) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = String(localized: "Your Weekly Digest")
|
||||||
|
content.body = headline
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "weekly-digest-\(UUID().uuidString)",
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to schedule digest notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Sends one notification from each personality pack, staggered over 10 seconds for screenshot
|
/// Sends one notification from each personality pack, staggered over 10 seconds for screenshot
|
||||||
public class func sendAllPersonalityNotificationsForScreenshot() {
|
public class func sendAllPersonalityNotificationsForScreenshot() {
|
||||||
|
|||||||
28
Shared/Models/AIEntryTags.swift
Normal file
28
Shared/Models/AIEntryTags.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// AIEntryTags.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// @Generable model for AI-extracted theme tags from mood entry notes and reflections.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// A single AI-extracted theme tag
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AITag: Equatable {
|
||||||
|
@Guide(description: "Theme label — one of: work, family, social, health, sleep, exercise, stress, gratitude, growth, creative, nature, self-care, finances, relationships, loneliness, motivation")
|
||||||
|
var label: String
|
||||||
|
|
||||||
|
@Guide(description: "Confidence level: high or medium")
|
||||||
|
var confidence: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container for extracted tags from a single entry
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIEntryTags: Equatable {
|
||||||
|
@Guide(description: "Array of 1-4 theme tags extracted from the text", .maximumCount(4))
|
||||||
|
var tags: [AITag]
|
||||||
|
}
|
||||||
26
Shared/Models/AIReflectionFeedback.swift
Normal file
26
Shared/Models/AIReflectionFeedback.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//
|
||||||
|
// AIReflectionFeedback.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// @Generable model for AI-powered reflection feedback after guided reflection completion.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// AI-generated personalized feedback after completing a guided reflection
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIReflectionFeedback: Equatable {
|
||||||
|
@Guide(description: "A warm, specific affirmation of what the user did well in their reflection (1 sentence)")
|
||||||
|
var affirmation: String
|
||||||
|
|
||||||
|
@Guide(description: "An observation connecting something the user wrote to a meaningful pattern or insight (1 sentence)")
|
||||||
|
var observation: String
|
||||||
|
|
||||||
|
@Guide(description: "A brief, actionable takeaway the user can carry forward (1 sentence)")
|
||||||
|
var takeaway: String
|
||||||
|
|
||||||
|
@Guide(description: "SF Symbol name for the feedback icon (e.g., sparkles, heart.fill, leaf.fill, star.fill)")
|
||||||
|
var iconName: String
|
||||||
|
}
|
||||||
64
Shared/Models/AIWeeklyDigest.swift
Normal file
64
Shared/Models/AIWeeklyDigest.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// AIWeeklyDigest.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// @Generable model and storage for AI-generated weekly emotional digest.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// AI-generated weekly mood digest
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIWeeklyDigestResponse: Equatable {
|
||||||
|
@Guide(description: "An engaging headline summarizing the week's emotional arc (3-7 words)")
|
||||||
|
var headline: String
|
||||||
|
|
||||||
|
@Guide(description: "A warm 2-3 sentence summary of the week's mood patterns and notable moments")
|
||||||
|
var summary: String
|
||||||
|
|
||||||
|
@Guide(description: "The best moment or strongest positive pattern from the week (1 sentence)")
|
||||||
|
var highlight: String
|
||||||
|
|
||||||
|
@Guide(description: "A gentle, actionable intention or suggestion for the coming week (1 sentence)")
|
||||||
|
var intention: String
|
||||||
|
|
||||||
|
@Guide(description: "SF Symbol name for the digest icon (e.g., sun.max.fill, leaf.fill, heart.fill)")
|
||||||
|
var iconName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storable weekly digest (Codable for UserDefaults persistence)
|
||||||
|
struct WeeklyDigest: Codable, Equatable {
|
||||||
|
let headline: String
|
||||||
|
let summary: String
|
||||||
|
let highlight: String
|
||||||
|
let intention: String
|
||||||
|
let iconName: String
|
||||||
|
let generatedAt: Date
|
||||||
|
let weekStartDate: Date
|
||||||
|
let weekEndDate: Date
|
||||||
|
|
||||||
|
var isFromCurrentWeek: Bool {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let currentWeekStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now
|
||||||
|
let digestWeekStart = calendar.dateInterval(of: .weekOfYear, for: weekStartDate)?.start ?? weekStartDate
|
||||||
|
return calendar.isDate(currentWeekStart, inSameDayAs: digestWeekStart) ||
|
||||||
|
calendar.isDate(digestWeekStart, inSameDayAs: calendar.date(byAdding: .weekOfYear, value: -1, to: currentWeekStart)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the digest was dismissed by the user
|
||||||
|
static var isDismissedKey: String { "weeklyDigestDismissedDate" }
|
||||||
|
|
||||||
|
static func markDismissed() {
|
||||||
|
GroupUserDefaults.groupDefaults.set(Date(), forKey: isDismissedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isDismissed(for digest: WeeklyDigest) -> Bool {
|
||||||
|
guard let dismissedDate = GroupUserDefaults.groupDefaults.object(forKey: isDismissedKey) as? Date else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return dismissedDate >= digest.generatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,11 +48,28 @@ final class MoodEntryModel {
|
|||||||
// Guided Reflection
|
// Guided Reflection
|
||||||
var reflectionJSON: String?
|
var reflectionJSON: String?
|
||||||
|
|
||||||
|
// AI-extracted theme tags (JSON array of strings)
|
||||||
|
var tagsJSON: String?
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
var mood: Mood {
|
var mood: Mood {
|
||||||
Mood(rawValue: moodValue) ?? .missing
|
Mood(rawValue: moodValue) ?? .missing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decoded tags from tagsJSON, or empty array if none
|
||||||
|
var tags: [String] {
|
||||||
|
guard let json = tagsJSON, let data = json.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode([String].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this entry has AI-extracted tags
|
||||||
|
var hasTags: Bool {
|
||||||
|
!tags.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
var moodString: String {
|
var moodString: String {
|
||||||
mood.strValue
|
mood.strValue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ extension DataController {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tags
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateTags(forDate date: Date, tagsJSON: String?) -> Bool {
|
||||||
|
guard let entry = getEntry(byDate: date) else { return false }
|
||||||
|
entry.tagsJSON = tagsJSON
|
||||||
|
saveAndRunDataListeners()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Photo
|
// MARK: - Photo
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ struct ReflectApp: App {
|
|||||||
guard let processingTask = task as? BGProcessingTask else { return }
|
guard let processingTask = task as? BGProcessingTask else { return }
|
||||||
BGTask.runWeatherRetryTask(task: processingTask)
|
BGTask.runWeatherRetryTask(task: processingTask)
|
||||||
}
|
}
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.weeklyDigestID, using: nil) { task in
|
||||||
|
guard let processingTask = task as? BGProcessingTask else { return }
|
||||||
|
BGTask.runWeeklyDigestTask(task: processingTask)
|
||||||
|
}
|
||||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||||
|
|
||||||
// Reset tips session on app launch
|
// Reset tips session on app launch
|
||||||
@@ -98,6 +102,7 @@ struct ReflectApp: App {
|
|||||||
}.onChange(of: scenePhase) { _, newPhase in
|
}.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .background {
|
if newPhase == .background {
|
||||||
BGTask.scheduleBackgroundProcessing()
|
BGTask.scheduleBackgroundProcessing()
|
||||||
|
BGTask.scheduleWeeklyDigest()
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
// Flush pending analytics events
|
// Flush pending analytics events
|
||||||
AnalyticsManager.shared.flush()
|
AnalyticsManager.shared.flush()
|
||||||
|
|||||||
155
Shared/Services/FoundationModelsDigestService.swift
Normal file
155
Shared/Services/FoundationModelsDigestService.swift
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// FoundationModelsDigestService.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Generates weekly emotional digests using Foundation Models.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@MainActor
|
||||||
|
class FoundationModelsDigestService {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = FoundationModelsDigestService()
|
||||||
|
|
||||||
|
private let summarizer = MoodDataSummarizer()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Storage Keys
|
||||||
|
|
||||||
|
private static let digestStorageKey = "latestWeeklyDigest"
|
||||||
|
|
||||||
|
// MARK: - Digest Generation
|
||||||
|
|
||||||
|
/// Generate a weekly digest from the past 7 days of mood data
|
||||||
|
func generateWeeklyDigest() async throws -> WeeklyDigest {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let weekStart = calendar.date(byAdding: .day, value: -7, to: now)!
|
||||||
|
|
||||||
|
let entries = DataController.shared.getData(
|
||||||
|
startDate: weekStart,
|
||||||
|
endDate: now,
|
||||||
|
includedDays: [1, 2, 3, 4, 5, 6, 7]
|
||||||
|
)
|
||||||
|
|
||||||
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
|
|
||||||
|
guard validEntries.count >= 3 else {
|
||||||
|
throw InsightGenerationError.insufficientData
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
|
let prompt = buildPrompt(entries: validEntries, weekStart: weekStart, weekEnd: now)
|
||||||
|
|
||||||
|
let response = try await session.respond(to: prompt, generating: AIWeeklyDigestResponse.self)
|
||||||
|
|
||||||
|
let digest = WeeklyDigest(
|
||||||
|
headline: response.content.headline,
|
||||||
|
summary: response.content.summary,
|
||||||
|
highlight: response.content.highlight,
|
||||||
|
intention: response.content.intention,
|
||||||
|
iconName: response.content.iconName,
|
||||||
|
generatedAt: Date(),
|
||||||
|
weekStartDate: weekStart,
|
||||||
|
weekEndDate: now
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store the digest
|
||||||
|
saveDigest(digest)
|
||||||
|
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the latest stored digest
|
||||||
|
func loadLatestDigest() -> WeeklyDigest? {
|
||||||
|
guard let data = GroupUserDefaults.groupDefaults.data(forKey: Self.digestStorageKey),
|
||||||
|
let digest = try? JSONDecoder().decode(WeeklyDigest.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Storage
|
||||||
|
|
||||||
|
private func saveDigest(_ digest: WeeklyDigest) {
|
||||||
|
if let data = try? JSONEncoder().encode(digest) {
|
||||||
|
GroupUserDefaults.groupDefaults.set(data, forKey: Self.digestStorageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Instructions
|
||||||
|
|
||||||
|
private var systemInstructions: String {
|
||||||
|
let personalityPack = UserDefaultsStore.personalityPackable()
|
||||||
|
|
||||||
|
switch personalityPack {
|
||||||
|
case .Default:
|
||||||
|
return """
|
||||||
|
You are a warm, supportive mood companion writing a weekly emotional digest. \
|
||||||
|
Summarize the week's mood journey with encouragement and specificity. \
|
||||||
|
Be personal, brief, and uplifting. Reference specific patterns from the data. \
|
||||||
|
SF Symbols: sun.max.fill, heart.fill, star.fill, leaf.fill, sparkles
|
||||||
|
"""
|
||||||
|
case .MotivationalCoach:
|
||||||
|
return """
|
||||||
|
You are a HIGH ENERGY motivational coach delivering a weekly performance review! \
|
||||||
|
Celebrate wins, frame challenges as growth opportunities, and fire them up for next week! \
|
||||||
|
Use exclamations and power language! \
|
||||||
|
SF Symbols: trophy.fill, flame.fill, bolt.fill, figure.run, star.fill
|
||||||
|
"""
|
||||||
|
case .ZenMaster:
|
||||||
|
return """
|
||||||
|
You are a calm Zen master offering a weekly reflection on the emotional journey. \
|
||||||
|
Use nature metaphors, gentle wisdom, and serene observations. Find meaning in all moods. \
|
||||||
|
SF Symbols: leaf.fill, moon.fill, drop.fill, sunrise.fill, wind
|
||||||
|
"""
|
||||||
|
case .BestFriend:
|
||||||
|
return """
|
||||||
|
You are their best friend doing a weekly check-in on how they've been. \
|
||||||
|
Be warm, casual, validating, and conversational. Celebrate with them, commiserate together. \
|
||||||
|
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, face.smiling.fill, balloon.fill
|
||||||
|
"""
|
||||||
|
case .DataAnalyst:
|
||||||
|
return """
|
||||||
|
You are a clinical data analyst delivering a weekly mood metrics report. \
|
||||||
|
Reference exact numbers, percentages, and observed trends. Be objective but constructive. \
|
||||||
|
SF Symbols: chart.bar.fill, chart.line.uptrend.xyaxis, number, percent, doc.text.magnifyingglass
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt Construction
|
||||||
|
|
||||||
|
private func buildPrompt(entries: [MoodEntryModel], weekStart: Date, weekEnd: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
|
||||||
|
let moodList = entries.sorted { $0.forDate < $1.forDate }.map { entry in
|
||||||
|
let day = entry.forDate.formatted(.dateTime.weekday(.abbreviated))
|
||||||
|
let mood = entry.mood.widgetDisplayName
|
||||||
|
let hasNotes = entry.notes != nil && !entry.notes!.isEmpty
|
||||||
|
let noteSnippet = hasNotes ? " (\(String(entry.notes!.prefix(50))))" : ""
|
||||||
|
return "\(day): \(mood)\(noteSnippet)"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
|
||||||
|
let summary = summarizer.summarize(entries: entries, periodName: "this week")
|
||||||
|
let avgMood = String(format: "%.1f", summary.averageMoodScore)
|
||||||
|
|
||||||
|
return """
|
||||||
|
Generate a weekly emotional digest for \(formatter.string(from: weekStart)) - \(formatter.string(from: weekEnd)):
|
||||||
|
|
||||||
|
\(moodList)
|
||||||
|
|
||||||
|
Average mood: \(avgMood)/5, Trend: \(summary.recentTrend), Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%
|
||||||
|
Current streak: \(summary.currentLoggingStreak) days
|
||||||
|
|
||||||
|
Write a warm, personalized weekly digest.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
128
Shared/Services/FoundationModelsReflectionService.swift
Normal file
128
Shared/Services/FoundationModelsReflectionService.swift
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
//
|
||||||
|
// FoundationModelsReflectionService.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Generates personalized AI feedback after a user completes a guided reflection.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@MainActor
|
||||||
|
class FoundationModelsReflectionService {
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
// MARK: - Feedback Generation
|
||||||
|
|
||||||
|
/// Generate personalized feedback based on a completed guided reflection
|
||||||
|
/// - Parameters:
|
||||||
|
/// - reflection: The completed guided reflection with Q&A responses
|
||||||
|
/// - mood: The mood associated with this entry
|
||||||
|
/// - Returns: AI-generated reflection feedback
|
||||||
|
func generateFeedback(
|
||||||
|
for reflection: GuidedReflection,
|
||||||
|
mood: Mood
|
||||||
|
) async throws -> AIReflectionFeedback {
|
||||||
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
|
|
||||||
|
let prompt = buildPrompt(from: reflection, mood: mood)
|
||||||
|
|
||||||
|
let response = try await session.respond(
|
||||||
|
to: prompt,
|
||||||
|
generating: AIReflectionFeedback.self
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.content
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Instructions
|
||||||
|
|
||||||
|
private var systemInstructions: String {
|
||||||
|
let personalityPack = UserDefaultsStore.personalityPackable()
|
||||||
|
|
||||||
|
switch personalityPack {
|
||||||
|
case .Default:
|
||||||
|
return defaultInstructions
|
||||||
|
case .MotivationalCoach:
|
||||||
|
return coachInstructions
|
||||||
|
case .ZenMaster:
|
||||||
|
return zenInstructions
|
||||||
|
case .BestFriend:
|
||||||
|
return bestFriendInstructions
|
||||||
|
case .DataAnalyst:
|
||||||
|
return analystInstructions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var defaultInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a warm, supportive companion responding to someone who just completed a guided mood reflection. \
|
||||||
|
Validate their effort, reflect their own words back to them, and offer a gentle takeaway. \
|
||||||
|
Be specific — reference what they actually wrote. Keep each field to 1 sentence. \
|
||||||
|
SF Symbols: sparkles, heart.fill, star.fill, sun.max.fill, leaf.fill
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var coachInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a HIGH ENERGY motivational coach responding to someone who just completed a guided mood reflection! \
|
||||||
|
Celebrate their self-awareness, pump them up about the growth they showed, and give them a power move for tomorrow. \
|
||||||
|
Reference what they actually wrote. Keep each field to 1 sentence. Use exclamations! \
|
||||||
|
SF Symbols: trophy.fill, flame.fill, bolt.fill, star.fill, figure.run
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var zenInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a calm, mindful guide responding to someone who just completed a guided mood reflection. \
|
||||||
|
Acknowledge their practice of self-awareness with gentle wisdom. Use nature metaphors. \
|
||||||
|
Reference what they actually wrote. Keep each field to 1 sentence. Speak with serene clarity. \
|
||||||
|
SF Symbols: leaf.fill, moon.fill, drop.fill, sunrise.fill, wind
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bestFriendInstructions: String {
|
||||||
|
"""
|
||||||
|
You are their supportive best friend responding after they completed a guided mood reflection. \
|
||||||
|
Be warm, casual, and validating. Use conversational tone. \
|
||||||
|
Reference what they actually wrote. Keep each field to 1 sentence. \
|
||||||
|
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, star.fill, face.smiling.fill
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var analystInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a clinical data analyst providing feedback on a completed mood reflection. \
|
||||||
|
Note the cognitive patterns observed, the technique application quality, and a data-informed recommendation. \
|
||||||
|
Reference what they actually wrote. Keep each field to 1 sentence. Be objective but encouraging. \
|
||||||
|
SF Symbols: chart.bar.fill, brain.head.profile, doc.text.magnifyingglass, chart.line.uptrend.xyaxis
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt Construction
|
||||||
|
|
||||||
|
private func buildPrompt(from reflection: GuidedReflection, mood: Mood) -> String {
|
||||||
|
let moodName = mood.widgetDisplayName
|
||||||
|
let technique = reflection.moodCategory.techniqueName
|
||||||
|
|
||||||
|
let qaPairs = reflection.responses
|
||||||
|
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
.map { response in
|
||||||
|
let chips = response.selectedChips.isEmpty ? "" : " [themes: \(response.selectedChips.joined(separator: ", "))]"
|
||||||
|
return "Q: \(response.question)\nA: \(response.answer)\(chips)"
|
||||||
|
}
|
||||||
|
.joined(separator: "\n\n")
|
||||||
|
|
||||||
|
return """
|
||||||
|
The user logged their mood as "\(moodName)" and completed a \(technique) reflection:
|
||||||
|
|
||||||
|
\(qaPairs)
|
||||||
|
|
||||||
|
Respond with personalized feedback that references their specific answers.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Shared/Services/FoundationModelsTagService.swift
Normal file
99
Shared/Services/FoundationModelsTagService.swift
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// FoundationModelsTagService.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Extracts theme tags from mood entry notes and guided reflections using Foundation Models.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@MainActor
|
||||||
|
class FoundationModelsTagService {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = FoundationModelsTagService()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Tag Extraction
|
||||||
|
|
||||||
|
/// Extract theme tags from an entry's note and/or reflection content
|
||||||
|
/// - Parameters:
|
||||||
|
/// - entry: The mood entry to extract tags from
|
||||||
|
/// - Returns: Array of tag label strings, or nil if extraction fails
|
||||||
|
func extractTags(for entry: MoodEntryModel) async -> [String]? {
|
||||||
|
// Need at least some text content to extract from
|
||||||
|
let noteText = entry.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let reflectionText = extractReflectionText(from: entry)
|
||||||
|
|
||||||
|
guard !noteText.isEmpty || !reflectionText.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
|
let prompt = buildPrompt(noteText: noteText, reflectionText: reflectionText, mood: entry.mood)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.respond(to: prompt, generating: AIEntryTags.self)
|
||||||
|
return response.content.tags.map { $0.label.lowercased() }
|
||||||
|
} catch {
|
||||||
|
print("Tag extraction failed: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract tags and save them to the entry via DataController
|
||||||
|
func extractAndSaveTags(for entry: MoodEntryModel) async {
|
||||||
|
guard let tags = await extractTags(for: entry), !tags.isEmpty else { return }
|
||||||
|
|
||||||
|
if let data = try? JSONEncoder().encode(tags),
|
||||||
|
let json = String(data: data, encoding: .utf8) {
|
||||||
|
DataController.shared.updateTags(forDate: entry.forDate, tagsJSON: json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Instructions
|
||||||
|
|
||||||
|
private var systemInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a theme extractor for a mood journal. Extract 1-4 theme tags from the user's journal text. \
|
||||||
|
Only use tags from this list: work, family, social, health, sleep, exercise, stress, gratitude, \
|
||||||
|
growth, creative, nature, self-care, finances, relationships, loneliness, motivation. \
|
||||||
|
Only extract tags clearly present in the text. Do not guess or infer themes not mentioned.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt Construction
|
||||||
|
|
||||||
|
private func buildPrompt(noteText: String, reflectionText: String, mood: Mood) -> String {
|
||||||
|
var content = "Mood: \(mood.widgetDisplayName)\n"
|
||||||
|
|
||||||
|
if !noteText.isEmpty {
|
||||||
|
content += "\nJournal note:\n\(String(noteText.prefix(500)))\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflectionText.isEmpty {
|
||||||
|
content += "\nReflection responses:\n\(String(reflectionText.prefix(800)))\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
content += "\nExtract theme tags from the text above."
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func extractReflectionText(from entry: MoodEntryModel) -> String {
|
||||||
|
guard let json = entry.reflectionJSON,
|
||||||
|
let reflection = GuidedReflection.decode(from: json) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflection.responses
|
||||||
|
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
.map { "Q: \($0.question)\nA: \($0.answer)" }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ struct GuidedReflectionView: View {
|
|||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
@State private var showDiscardAlert = false
|
@State private var showDiscardAlert = false
|
||||||
@State private var showInfoSheet = false
|
@State private var showInfoSheet = false
|
||||||
|
@State private var showFeedback = false
|
||||||
|
@State private var savedReflection: GuidedReflection?
|
||||||
|
|
||||||
private let initialDraft: GuidedReflectionDraft
|
private let initialDraft: GuidedReflectionDraft
|
||||||
|
|
||||||
@@ -77,8 +79,24 @@ struct GuidedReflectionView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollViewReader { proxy in
|
ZStack {
|
||||||
reflectionSheetContent(with: proxy)
|
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 {
|
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 {
|
} else {
|
||||||
isSaving = false
|
isSaving = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ struct InsightsView: View {
|
|||||||
@EnvironmentObject var iapManager: IAPManager
|
@EnvironmentObject var iapManager: IAPManager
|
||||||
@State private var showSubscriptionStore = false
|
@State private var showSubscriptionStore = false
|
||||||
@State private var selectedTab: InsightsTab = .insights
|
@State private var selectedTab: InsightsTab = .insights
|
||||||
|
@State private var weeklyDigest: WeeklyDigest?
|
||||||
|
@State private var showDigest = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -94,15 +96,34 @@ struct InsightsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
AnalyticsManager.shared.trackScreen(.insights)
|
AnalyticsManager.shared.trackScreen(.insights)
|
||||||
viewModel.generateInsights()
|
viewModel.generateInsights()
|
||||||
|
loadWeeklyDigest()
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Insights Content
|
// 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 {
|
private var insightsContent: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
|
// Weekly Digest Card
|
||||||
|
if showDigest, let digest = weeklyDigest {
|
||||||
|
WeeklyDigestCardView(digest: digest) {
|
||||||
|
showDigest = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// This Month Section
|
// This Month Section
|
||||||
InsightsSectionView(
|
InsightsSectionView(
|
||||||
title: "This Month",
|
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)
|
let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave)
|
||||||
|
|
||||||
if success {
|
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()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
isSaving = false
|
isSaving = false
|
||||||
@@ -186,6 +192,11 @@ struct EntryDetailView: View {
|
|||||||
// Mood section
|
// Mood section
|
||||||
moodSection
|
moodSection
|
||||||
|
|
||||||
|
// Tags section
|
||||||
|
if entry.hasTags {
|
||||||
|
tagsSection
|
||||||
|
}
|
||||||
|
|
||||||
// Guided reflection section
|
// Guided reflection section
|
||||||
if currentMood != .missing && currentMood != .placeholder {
|
if currentMood != .missing && currentMood != .placeholder {
|
||||||
reflectionSection
|
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 {
|
private var notesSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
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