diff --git a/Reflect--iOS--Info.plist b/Reflect--iOS--Info.plist
index 062fe43..5039a35 100644
--- a/Reflect--iOS--Info.plist
+++ b/Reflect--iOS--Info.plist
@@ -6,6 +6,7 @@
com.88oakapps.reflect.dbUpdateMissing
com.88oakapps.reflect.weatherRetry
+ com.88oakapps.reflect.weeklyDigest
NSLocationWhenInUseUsageDescription
Reflect uses your location to show weather details for your mood entries.
diff --git a/Shared/AccessibilityIdentifiers.swift b/Shared/AccessibilityIdentifiers.swift
index f437489..47cd51d 100644
--- a/Shared/AccessibilityIdentifiers.swift
+++ b/Shared/AccessibilityIdentifiers.swift
@@ -97,6 +97,21 @@ enum AccessibilityID {
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
enum Settings {
static let header = "settings_header"
diff --git a/Shared/BGTask.swift b/Shared/BGTask.swift
index a01c74d..77dfb76 100644
--- a/Shared/BGTask.swift
+++ b/Shared/BGTask.swift
@@ -11,6 +11,7 @@ import BackgroundTasks
class BGTask {
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
+ static let weeklyDigestID = "com.88oakapps.reflect.weeklyDigest"
@MainActor
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() {
let request = BGProcessingTaskRequest(identifier: BGTask.updateDBMissingID)
request.requiresNetworkConnectivity = false
diff --git a/Shared/LocalNotification.swift b/Shared/LocalNotification.swift
index a77882d..447dd3a 100644
--- a/Shared/LocalNotification.swift
+++ b/Shared/LocalNotification.swift
@@ -135,6 +135,28 @@ class LocalNotification {
// 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
/// Sends one notification from each personality pack, staggered over 10 seconds for screenshot
public class func sendAllPersonalityNotificationsForScreenshot() {
diff --git a/Shared/Models/AIEntryTags.swift b/Shared/Models/AIEntryTags.swift
new file mode 100644
index 0000000..784d3e3
--- /dev/null
+++ b/Shared/Models/AIEntryTags.swift
@@ -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]
+}
diff --git a/Shared/Models/AIReflectionFeedback.swift b/Shared/Models/AIReflectionFeedback.swift
new file mode 100644
index 0000000..41f4b90
--- /dev/null
+++ b/Shared/Models/AIReflectionFeedback.swift
@@ -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
+}
diff --git a/Shared/Models/AIWeeklyDigest.swift b/Shared/Models/AIWeeklyDigest.swift
new file mode 100644
index 0000000..bc67fe1
--- /dev/null
+++ b/Shared/Models/AIWeeklyDigest.swift
@@ -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
+ }
+}
diff --git a/Shared/Models/MoodEntryModel.swift b/Shared/Models/MoodEntryModel.swift
index c0d69e7..f8c1bf4 100644
--- a/Shared/Models/MoodEntryModel.swift
+++ b/Shared/Models/MoodEntryModel.swift
@@ -48,11 +48,28 @@ final class MoodEntryModel {
// Guided Reflection
var reflectionJSON: String?
+ // AI-extracted theme tags (JSON array of strings)
+ var tagsJSON: String?
+
// Computed properties
var mood: Mood {
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 {
mood.strValue
}
diff --git a/Shared/Persisence/DataControllerUPDATE.swift b/Shared/Persisence/DataControllerUPDATE.swift
index 880e57a..cd25e96 100644
--- a/Shared/Persisence/DataControllerUPDATE.swift
+++ b/Shared/Persisence/DataControllerUPDATE.swift
@@ -63,6 +63,16 @@ extension DataController {
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
@discardableResult
diff --git a/Shared/ReflectApp.swift b/Shared/ReflectApp.swift
index 308c4dc..f62f714 100644
--- a/Shared/ReflectApp.swift
+++ b/Shared/ReflectApp.swift
@@ -40,6 +40,10 @@ struct ReflectApp: App {
guard let processingTask = task as? BGProcessingTask else { return }
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)
// Reset tips session on app launch
@@ -98,6 +102,7 @@ struct ReflectApp: App {
}.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
BGTask.scheduleBackgroundProcessing()
+ BGTask.scheduleWeeklyDigest()
WidgetCenter.shared.reloadAllTimelines()
// Flush pending analytics events
AnalyticsManager.shared.flush()
diff --git a/Shared/Services/FoundationModelsDigestService.swift b/Shared/Services/FoundationModelsDigestService.swift
new file mode 100644
index 0000000..c102912
--- /dev/null
+++ b/Shared/Services/FoundationModelsDigestService.swift
@@ -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.
+ """
+ }
+}
diff --git a/Shared/Services/FoundationModelsReflectionService.swift b/Shared/Services/FoundationModelsReflectionService.swift
new file mode 100644
index 0000000..b9364d8
--- /dev/null
+++ b/Shared/Services/FoundationModelsReflectionService.swift
@@ -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.
+ """
+ }
+}
diff --git a/Shared/Services/FoundationModelsTagService.swift b/Shared/Services/FoundationModelsTagService.swift
new file mode 100644
index 0000000..b3b2513
--- /dev/null
+++ b/Shared/Services/FoundationModelsTagService.swift
@@ -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")
+ }
+}
diff --git a/Shared/Views/GuidedReflectionView.swift b/Shared/Views/GuidedReflectionView.swift
index a6a1f93..b62b5c1 100644
--- a/Shared/Views/GuidedReflectionView.swift
+++ b/Shared/Views/GuidedReflectionView.swift
@@ -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
}
diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift
index b49f6f8..5d996dd 100644
--- a/Shared/Views/InsightsView/InsightsView.swift
+++ b/Shared/Views/InsightsView/InsightsView.swift
@@ -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",
diff --git a/Shared/Views/InsightsView/WeeklyDigestCardView.swift b/Shared/Views/InsightsView/WeeklyDigestCardView.swift
new file mode 100644
index 0000000..7b1298c
--- /dev/null
+++ b/Shared/Views/InsightsView/WeeklyDigestCardView.swift
@@ -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))"
+ }
+}
diff --git a/Shared/Views/NoteEditorView.swift b/Shared/Views/NoteEditorView.swift
index 360b6bc..b030b8c 100644
--- a/Shared/Views/NoteEditorView.swift
+++ b/Shared/Views/NoteEditorView.swift
@@ -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 {
diff --git a/Shared/Views/ReflectionFeedbackView.swift b/Shared/Views/ReflectionFeedbackView.swift
new file mode 100644
index 0000000..45732c3
--- /dev/null
+++ b/Shared/Views/ReflectionFeedbackView.swift
@@ -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())
+ }
+}