From ab8d8fbdc05a6ca721ec644e7f8a9b60afc7fe7c Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 4 Apr 2026 00:47:28 -0500 Subject: [PATCH] Add AI-powered mental wellness features: Reflection Companion, Pattern Tags, Weekly Digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Reflect--iOS--Info.plist | 1 + Shared/AccessibilityIdentifiers.swift | 15 ++ Shared/BGTask.swift | 60 +++++ Shared/LocalNotification.swift | 22 ++ Shared/Models/AIEntryTags.swift | 28 +++ Shared/Models/AIReflectionFeedback.swift | 26 +++ Shared/Models/AIWeeklyDigest.swift | 64 +++++ Shared/Models/MoodEntryModel.swift | 17 ++ Shared/Persisence/DataControllerUPDATE.swift | 10 + Shared/ReflectApp.swift | 5 + .../FoundationModelsDigestService.swift | 155 +++++++++++++ .../FoundationModelsReflectionService.swift | 128 ++++++++++ .../Services/FoundationModelsTagService.swift | 99 ++++++++ Shared/Views/GuidedReflectionView.swift | 39 +++- Shared/Views/InsightsView/InsightsView.swift | 21 ++ .../InsightsView/WeeklyDigestCardView.swift | 130 +++++++++++ Shared/Views/NoteEditorView.swift | 40 ++++ Shared/Views/ReflectionFeedbackView.swift | 219 ++++++++++++++++++ 18 files changed, 1076 insertions(+), 3 deletions(-) create mode 100644 Shared/Models/AIEntryTags.swift create mode 100644 Shared/Models/AIReflectionFeedback.swift create mode 100644 Shared/Models/AIWeeklyDigest.swift create mode 100644 Shared/Services/FoundationModelsDigestService.swift create mode 100644 Shared/Services/FoundationModelsReflectionService.swift create mode 100644 Shared/Services/FoundationModelsTagService.swift create mode 100644 Shared/Views/InsightsView/WeeklyDigestCardView.swift create mode 100644 Shared/Views/ReflectionFeedbackView.swift 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()) + } +}