Compare commits

..

16 Commits

Author SHA1 Message Date
Trey T
1d1b7366d1 Bring localization to 100% across all 6 languages, drop dead keys
Adds 24 missing translations (144 strings) for the CBT reflection flow:
distortion keyword lists, Socratic question templates, intensity labels,
and the specificity probe. Removes 26 dead keys — blank entries, unused
guided chip keys, superseded reflection questions replaced by templated
variants, and orphaned onboarding copy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:59:52 -05:00
Trey T
7683717240 Add full repo README with feature docs and CBT reflection deep dive
Covers every feature with how-it-works, selling-point framing, and
clickable source links — targeted at both developers and business
partners. Includes an in-depth section on the guided reflection flow
(Socratic templating, cognitive-distortion routing, evidence step).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:08:12 -05:00
Trey t
f4a3d83304 Merge branch 'main' of gitea.treytartt.com:admin/Reflect
# Conflicts:
#	Reflect/Localizable.xcstrings
2026-04-14 18:51:37 -05:00
Trey t
e2b32c014c Update Localizable.xcstrings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:51:07 -05:00
Trey T
cc4143d3ea Expand guided reflection with CBT thought record and distortion routing
Adds a 5-step negative-mood reflection flow with an evidence-examination
step, Socratic templated questions that back-reference prior answers, and
a deterministic cognitive-distortion detector that routes the perspective-
check prompt to a distortion-specific reframe. Includes CBT plan docs,
flowchart, stats research notes, and MCP config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:49:39 -05:00
Trey T
e6a34a0f25 Fix stuck "Generating Insights" modal overlay
Set all three loading states to .loading upfront before entering the
task group, and remove .idle from the modal visibility condition. This
prevents the overlay from staying visible when tasks complete at
different rates while others remain in .idle state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:43:32 -05:00
Trey t
5fd50e1a84 Complete localization coverage to 100% across all 6 languages
Translate 24 previously missing strings for de, es, fr, ja, ko, pt-BR:
- CBT/ACT therapeutic step labels (Situation, Reframe, Defusion, etc.)
- Guided reflection info view content and disclaimer
- Temperature format string, Continue, Get Started
- Debug digest button description

676/676 strings now translated across all languages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:59:42 -05:00
Trey t
b0cd4be8d7 Add AI enablement guidance with reason-specific UI and localized translations
Show specific guidance when Apple Intelligence is unavailable:
- Device not eligible: "iPhone 15 Pro or later required"
- Not enabled: step-by-step path + "Open Settings" button
- Model downloading: "Please wait" + "Try Again" button
- Pre-iOS 26: "Update required"

Auto re-checks availability when app returns to foreground so enabling
Apple Intelligence in Settings immediately triggers insight generation.

Adds translations for all new AI strings across de, es, fr, ja, ko, pt-BR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:36:04 -05:00
Trey t
7a6c4056d8 Merge branch 'main' of github.com:akatreyt/Feels 2026-04-04 13:40:42 -05:00
Trey t
70400b7790 Optimize AI generation speed and add richer insight data
Speed optimizations:
- Add session.prewarm() in InsightsViewModel and ReportsViewModel init
  for 40% faster first-token latency
- Cap maximumResponseTokens on all 8 AI respond() calls (100-600 per use case)
- Add prompt brevity constraints ("1-2 sentences", "2 sentences")
- Reduce report batch concurrency from 4 to 2 to prevent device contention
- Pre-fetch health data once and share across all 3 insight periods

Richer insight data in MoodDataSummarizer:
- Tag-mood correlations: overall frequency + good day vs bad day tag breakdown
- Weather-mood correlations: avg mood by condition and temperature range
- Absence pattern detection: logging gap count with pre/post-gap mood averages
- Entry source breakdown: % of entries from App, Widget, Watch, Siri, etc.
- Update insight prompt to leverage tags, weather, and gap data when available

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:52:14 -05:00
Trey t
329fb7c671 Remove #if DEBUG guards for TestFlight, polish weekly digest and insights UX
- Remove #if DEBUG from all debug settings, exporters, and IAP bypass so
  debug options are available in TestFlight builds
- Weekly digest card: replace dismiss X with collapsible chevron caret
- Weekly digest: generate on-demand when opening Insights tab if no cached
  digest exists (BGTask + notification kept as bonus path)
- Fix digest intention text color (was .secondary, now uses theme textColor)
- Add "Generate Weekly Digest" debug button in Settings
- Add generating overlay on Insights tab with pulsing sparkles icon that
  stays visible until all sections finish loading (content at 0.2 opacity)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:15:23 -05:00
Trey t
ab8d8fbdc0 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>
2026-04-04 00:47:28 -05:00
Trey t
43ff239781 Fix guided reflection chip suggestions to align with questions
Negative Q4 (Reframe): Moved cognitive reframes to top row (challenge
worst-case, separate facts from feelings, etc.) and demoted action
chips (take a walk, get rest) to expanded. Added two new reframe chips.

Positive Q2 (Awareness): Replaced single emotion words (Joy, Gratitude)
with moment-oriented suggestions (A conversation that made me smile,
Something I accomplished) to match "what moment stands out?" question.

Added translations for 14 new localization keys across all 7 languages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:17:42 -05:00
Trey T
1f040ab676 v1.1 polish: accessibility, error logging, localization, and code quality sweep
- Wrap 30+ production print() statements in #if DEBUG guards across 18 files
- Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets
- Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views
- Add text alternatives for color-only indicators (progress dots, mood circles)
- Localize raw string literals in NoteEditorView, EntryDetailView, widgets
- Replace 25+ silent try? with do/catch + AppLogger error logging
- Replace hardcoded font sizes with semantic Dynamic Type fonts
- Fix FIXME in IconPickerView (log icon change errors)
- Extract magic animation delays to named constants across 8 files
- Add widget empty state "Log your first mood!" messaging
- Hide decorative images from VoiceOver, add labels to ColorPickers
- Remove stale TODO in Color+Codable (alpha change deferred for migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:14 -05:00
Trey T
4d9e906c4d Fix mood buttons hidden from accessibility tree in all voting layouts
The parent container's .accessibilityElement(children: .contain) and
.accessibilityLabel were collapsing individual mood buttons into a single
group, making them invisible to accessibility tools like AXe and VoiceOver.

Fix: Add .accessibilityElement(children: .ignore) and .accessibilityAddTraits
(.isButton) to each individual mood button, and remove the group-level
accessibility modifiers. Applied to all 6 voting layouts (Horizontal, Card,
Stacked, Aura, Orbit, Neon).
2026-03-26 09:00:38 -05:00
Trey T
ed8205cd88 Complete accessibility identifier coverage across all 152 project files
Exhaustive file-by-file audit of every Swift file in the project (iOS app,
Watch app, Widget extension). Every interactive UI element — buttons, toggles,
pickers, links, menus, tap gestures, text editors, color pickers, photo
pickers — now has an accessibilityIdentifier for XCUITest automation.

46 files changed across Shared/, Onboarding/, Watch App/, and Widget targets.
Added ~100 new ID definitions covering settings debug controls, export/photo
views, sharing templates, customization subviews, onboarding flows, tip
modals, widget voting buttons, and watch mood buttons.
2026-03-26 08:34:56 -05:00
95 changed files with 25596 additions and 18671 deletions

10
.mcp.json Normal file
View File

@@ -0,0 +1,10 @@
{
"mcpServers": {
"github-webhook": {
"command": "bun",
"args": [
"/Users/m4mini/Desktop/code/github-webhook-channel/webhook.ts"
]
}
}
}

476
README.md Normal file
View File

@@ -0,0 +1,476 @@
# Reflect
A private, on-device iOS mood tracker that turns a five-second daily check-in into durable self-knowledge. Users rate their day on a 5-point scale, and the app turns that signal into calendars, trends, Apple-Intelligence-powered insights, guided CBT reflections, watch complications, widgets, Live Activities, and shareable summaries.
> **Platform:** iOS 16+ · iPadOS · watchOS · macOS (Catalyst)
> **Language:** Swift / SwiftUI
> **Data:** SwiftData + CloudKit (private database)
> **AI:** Apple Foundation Models (on-device, iOS 26+)
> **Monetization:** 30-day free trial → monthly / yearly subscription (StoreKit 2)
---
## Table of Contents
- [Elevator pitch](#elevator-pitch)
- [Feature map](#feature-map)
- [Core features in depth](#core-features-in-depth)
- [Daily mood logging](#1-daily-mood-logging)
- [Day / Month / Year views](#2-day--month--year-views)
- [Insights (AI)](#3-insights-ai)
- [Weekly Digest (AI)](#4-weekly-digest-ai)
- [Auto-tagging (AI)](#5-auto-tagging-ai)
- [AI Reports + PDF export](#6-ai-reports--pdf-export)
- [Guided Reflection (CBT / ACT / BA)](#7-guided-reflection--deep-dive)
- [Widgets](#8-widgets)
- [Live Activities](#9-live-activities)
- [Watch app + complications](#10-watch-app--complications)
- [Control Center, Siri, App Intents, Shortcuts](#11-control-center-siri-app-intents-shortcuts)
- [HealthKit State of Mind](#12-healthkit-state-of-mind)
- [WeatherKit correlation](#13-weatherkit-correlation)
- [Photos on entries](#14-photos-on-entries)
- [Face ID / Touch ID lock](#15-face-id--touch-id-lock)
- [Sharing templates](#16-sharing-templates)
- [Customization](#17-customization)
- [Notifications + personality packs](#18-notifications--personality-packs)
- [Export / Import](#19-export--import)
- [Subscriptions](#20-subscriptions)
- [Guided Reflection — deep dive](#guided-reflection--deep-dive)
- [Architecture](#architecture)
- [Data model](#data-model)
- [Directory map](#directory-map)
- [Build, run, test](#build-run-test)
- [Localization](#localization)
- [Privacy & security](#privacy--security)
- [Configuration reference](#configuration-reference)
---
## Elevator pitch
Most mood trackers collect data. **Reflect converts it into insight.** The five-second daily tap is only the entry point — behind it sits a full behavioral-health stack:
- **On-device Apple Intelligence** (Foundation Models) writes your weekly digest, surfaces patterns, and runs a cognitive-behavioral-therapy-aligned reflection flow. Nothing leaves the phone.
- **CloudKit sync** keeps iPhone and Apple Watch in step without an account.
- **HealthKit State of Mind** writes your rating back into Apple Health and reads activity / sleep / HRV so the app can correlate mood with the rest of your life.
- **A CBT-aligned Guided Reflection** detects cognitive distortions in your automatic thoughts and rewrites the follow-up question to match the specific distortion — the actual mechanism of change in evidence-based CBT.
- **Four surfaces to log from**: app, home/lock-screen widget, Control Center widget, Apple Watch complication. One tap, anywhere.
The thesis: a mood tracker is only useful if the patterns it surfaces can drive behavior change. Reflect is designed from the top down around the CBT/ACT evidence base, not screen time.
---
## Feature map
| Area | What it is | Why it sells | Source |
|---|---|---|---|
| 5-point mood log | Horrible → Great daily rating | The lowest-friction signal that still captures variance | [`Shared/Models/Mood.swift`](Shared/Models/Mood.swift) |
| Day view | Chronological list with notes, photos, weather | Diary + heatmap in one scroll | [`Shared/Views/DayView/`](Shared/Views/DayView) |
| Month view | Calendar heatmap | "What kind of month was this?" in one glance | [`Shared/Views/MonthView/`](Shared/Views/MonthView) |
| Year view | 12-month heatmap + filters | See multi-month seasonality | [`Shared/Views/YearView/`](Shared/Views/YearView) |
| AI Insights | Patterns, predictions, advice | Turns data into behavior change | [`Shared/Views/InsightsView/`](Shared/Views/InsightsView) · [`FoundationModelsInsightService.swift`](Shared/Services/FoundationModelsInsightService.swift) |
| Weekly Digest | AI-written summary of the last 7 days | Sunday-night ritual, builds habit | [`FoundationModelsDigestService.swift`](Shared/Services/FoundationModelsDigestService.swift) |
| Auto-tagging | AI extracts themes (work, sleep, family…) | Enables mood↔theme correlation | [`FoundationModelsTagService.swift`](Shared/Services/FoundationModelsTagService.swift) |
| AI Reports + PDF | Clinical multi-week reports | Shareable with therapist | [`ReportPDFGenerator.swift`](Shared/Services/ReportPDFGenerator.swift) |
| Guided Reflection | CBT Thought Record / ACT Defusion / Behavioral Activation | Evidence-based, not a mood journal | [`GuidedReflectionView.swift`](Shared/Views/GuidedReflectionView.swift) · [`GuidedReflection.swift`](Shared/Models/GuidedReflection.swift) |
| Cognitive distortion detector | Routes reframes to specific distortion | The mechanism of change in CBT | [`CognitiveDistortionDetector.swift`](Shared/Services/CognitiveDistortionDetector.swift) |
| Widgets | 6 widget families | Logging from the home screen | [`ReflectWidget/`](ReflectWidget) |
| Live Activities | Lock Screen + Dynamic Island streak | Keeps the habit present | [`ReflectLiveActivity.swift`](ReflectWidget/ReflectLiveActivity.swift) · [`MoodStreakActivity.swift`](Shared/MoodStreakActivity.swift) |
| Watch app | Log and view on wrist | Frictionless logging | [`Reflect Watch App/`](Reflect%20Watch%20App) |
| Complications | Streak / last mood on watch face | Ambient nudge to log | [`ReflectComplication.swift`](Reflect%20Watch%20App/ReflectComplication.swift) |
| Control Center | iOS 18 Control Center widget | One-tap log from anywhere | [`ReflectMoodControlWidget.swift`](ReflectWidget/ReflectMoodControlWidget.swift) |
| Siri / Shortcuts | App Intents for "Log mood great" | Hands-free logging | [`AppShortcuts.swift`](Shared/AppShortcuts.swift) · [`SharedMoodIntent.swift`](Shared/SharedMoodIntent.swift) |
| HealthKit | State of Mind write + activity read | Deep Apple Health integration | [`HealthKitManager.swift`](Shared/HealthKitManager.swift) · [`HealthService.swift`](Shared/Services/HealthService.swift) |
| WeatherKit | Attach weather to each entry | Mood↔weather correlation in Insights | [`WeatherManager.swift`](Shared/Services/WeatherManager.swift) |
| Photos | Attach a photo per day | Visual journal | [`PhotoManager.swift`](Shared/Services/PhotoManager.swift) |
| Biometric lock | Face ID / Touch ID / Optic ID | Private data stays private | [`BiometricAuthManager.swift`](Shared/Services/BiometricAuthManager.swift) |
| Sharing templates | Mood-style social cards | Organic growth channel | [`Shared/Views/Sharing/`](Shared/Views/Sharing) · [`Shared/Views/SharingTemplates/`](Shared/Views/SharingTemplates) |
| Custom icon widget | User-designed widget layout | Personalization as retention | [`Shared/Views/CustomWidget/`](Shared/Views/CustomWidget) |
| Theming | Color sets, icon packs, shapes, personality | Make it feel like yours | [`Shared/Views/CustomizeView/`](Shared/Views/CustomizeView) |
| Export | CSV / JSON / PDF | No lock-in — builds trust | [`ExportService.swift`](Shared/Services/ExportService.swift) |
| Subscription | Monthly / yearly + 30-day trial | StoreKit 2, offer codes | [`IAPManager.swift`](Shared/IAPManager.swift) |
---
## Core features in depth
### 1. Daily mood logging
- **What:** A 5-point ordinal scale — Horrible, Bad, Average, Good, Great — defined in [`Shared/Models/Mood.swift`](Shared/Models/Mood.swift).
- **How:** Every mood entry — from the app, a widget, the watch, Siri, the lock-screen Live Activity, a push-notification action, or an imported CSV row — funnels through a single choke point: **[`MoodLogger.shared.logMood(...)`](Shared/MoodLogger.swift)**. That one method runs the write to SwiftData, the HealthKit sync, streak recomputation, widget-timeline reload, Watch Connectivity nudge, TipKit parameter update, Live Activity refresh, and analytics event.
- **Why 5 points:** Enough to capture variance, few enough to fit on a widget. The ordinal scale is what lets us run [`MoodMetrics.swift`](Shared/Models/MoodMetrics.swift) over it without arguing about arithmetic on emoji.
- **Source-of-truth rule:** never insert into `modelContext` directly — always go through `MoodLogger`, or side effects silently break. This invariant is enforced in [`CLAUDE.md`](CLAUDE.md#mutation--write-patterns).
### 2. Day / Month / Year views
Three zoom levels over the same data, selected via the tab bar in [`MainTabView.swift`](Shared/Views/MainTabView.swift).
- **[Day](Shared/Views/DayView)** — Chronological list of entries with notes, photo, weather card, and the "open reflection" CTA. Backed by [`DayViewViewModel`](Shared/Views/DayView).
- **[Month](Shared/Views/MonthView)** — Classic calendar grid, each cell tinted by its mood. Tap to drill into that date. Shape is user-configurable (circle / square / rounded / diamond).
- **[Year](Shared/Views/YearView)** — 12 mini calendars stacked for seasonality. Users can filter by weekday (e.g. "only show Mondays") to isolate work-week patterns.
### 3. Insights (AI)
- **What:** An on-device AI pass over your mood history that returns a short, human-readable list of patterns, predictions, advice, and achievements, tinted to the dominant mood.
- **How:** [`FoundationModelsInsightService.swift`](Shared/Services/FoundationModelsInsightService.swift) uses Apple Foundation Models (`LanguageModelSession` with a `@Generable` schema defined in [`AIInsight.swift`](Shared/Models/AIInsight.swift)). The prompt is built by [`MoodDataSummarizer.swift`](Shared/Services/MoodDataSummarizer.swift), which precomputes mood distribution, day-of-week patterns, streaks, weather correlations, and HealthKit correlations so the model only has to narrate facts we already have.
- **Caching:** 1-hour result cache — regenerating is one tap.
- **Selling point:** Zero network, zero account, zero data leaves the device.
### 4. Weekly Digest (AI)
- **What:** Every week the app writes you a one-paragraph summary with a headline, a highlight ("your best day was Thursday — walk after work"), and an intention for next week.
- **How:** [`FoundationModelsDigestService.swift`](Shared/Services/FoundationModelsDigestService.swift) generates against the [`AIWeeklyDigest`](Shared/Models/AIWeeklyDigest.swift) schema. Scheduled by [`BGTask.runWeeklyDigestTask`](Shared/BGTask.swift) so it's ready on Sunday night without the user opening the app. Delivered via a local notification; rendered in [`WeeklyDigestCardView.swift`](Shared/Views/InsightsView).
- **Why it sells:** A weekly "here's what happened" ritual is how habits become durable.
### 5. Auto-tagging (AI)
- **What:** Themes like *work / sleep / family / exercise / stress* are extracted automatically from each entry's notes and reflection.
- **How:** [`FoundationModelsTagService.swift`](Shared/Services/FoundationModelsTagService.swift) returns an [`AIEntryTags`](Shared/Models/AIEntryTags.swift). Insights cross-reference the tag with the mood to answer questions like "what happens to your mood on days you write about sleep?".
- **Why it sells:** Users don't have to tag manually — the signal is free.
### 6. AI Reports + PDF export
- **What:** A multi-week clinical-style report with overview stats, week-by-week breakdowns, and visualizations, exportable as a PDF to share with a therapist or doctor.
- **How:** [`ReportPDFGenerator.swift`](Shared/Services/ReportPDFGenerator.swift) renders an HTML template through WKWebView and rasterizes to PDF. Data shape is [`AIReport`](Shared/Models/AIReport.swift); UI lives in [`Shared/Views/InsightsView/ReportsView.swift`](Shared/Views/InsightsView).
- **Why it sells:** Unlocks clinical use cases — a real moat vs. tap-to-log competitors.
### 7. Guided Reflection — deep dive
See the dedicated [Guided Reflection section below](#guided-reflection--deep-dive).
### 8. Widgets
All six live in [`ReflectWidget/`](ReflectWidget) and are bundled by [`WidgetBundle.swift`](ReflectWidget/WidgetBundle.swift).
| Widget | File | Purpose |
|---|---|---|
| Vote | [`ReflectVoteWidget.swift`](ReflectWidget/ReflectVoteWidget.swift) | Interactive 5-button logger — small/medium, no app launch |
| Timeline | [`ReflectTimelineWidget.swift`](ReflectWidget/ReflectTimelineWidget.swift) | Recent-days strip; pre-vote shows buttons, post-vote shows stats |
| Graphic | [`ReflectGraphicWidget.swift`](ReflectWidget/ReflectGraphicWidget.swift) | Large mood art widget |
| Icon | [`ReflectIconWidget.swift`](ReflectWidget/ReflectIconWidget.swift) | User-customized widget (see [CustomWidget](Shared/Views/CustomWidget)) |
| Mood Control | [`ReflectMoodControlWidget.swift`](ReflectWidget/ReflectMoodControlWidget.swift) | **Control Center** widget — iOS 18 |
| Live Activity | [`ReflectLiveActivity.swift`](ReflectWidget/ReflectLiveActivity.swift) | Lock Screen + Dynamic Island |
Widgets can't reach CloudKit or HealthKit from their process, so they read through [`ExtensionDataProvider.swift`](Shared/Persisence/ExtensionDataProvider.swift), which talks to the App Group container directly. When the widget writes a vote via an `AppIntent`, the main app picks it up and runs the deferred `MoodLogger` side effects next launch.
### 9. Live Activities
- **What:** A streak card on the Lock Screen and in the Dynamic Island, updating in real time when you log or when the voting window opens.
- **How:** [`MoodStreakActivity.swift`](Shared/MoodStreakActivity.swift) starts the `ActivityKit` activity; [`ReflectLiveActivity.swift`](ReflectWidget/ReflectLiveActivity.swift) is the view. [`LiveActivityScheduler`](Shared/MoodStreakActivity.swift) decides when the activity should be on-screen (typically after the daily reminder fires until the day is logged).
- **Why it sells:** Continuous visible nudge without being a notification — drives logging consistency.
### 10. Watch app + complications
- **[`Reflect Watch App/ContentView.swift`](Reflect%20Watch%20App/ContentView.swift)** — Full voting UI on the wrist. Writes go through the shared CloudKit database; UI refresh for the phone happens over [`WatchConnectivityManager`](Shared/Services/WatchConnectivityManager.swift).
- **[`ReflectComplication.swift`](Reflect%20Watch%20App/ReflectComplication.swift)** — Circular / rectangular / corner complications showing streak, last mood, or graphic.
- **Selling point:** the watch is the lowest-friction surface — users raise their wrist, tap, done.
### 11. Control Center, Siri, App Intents, Shortcuts
- **[`AppShortcuts.swift`](Shared/AppShortcuts.swift)** — Exposes "Log a mood" and "Open Reflect" as App Intents so Siri and Shortcuts can call them.
- **[`SharedMoodIntent.swift`](Shared/SharedMoodIntent.swift)** — The intent shared across widget buttons and voice actions.
- **[`ReflectMoodControlWidget.swift`](ReflectWidget/ReflectMoodControlWidget.swift)** — iOS 18 Control Center widget that opens the app pre-scrolled to the log screen.
### 12. HealthKit State of Mind
- **[`HealthKitManager.swift`](Shared/HealthKitManager.swift)** — Writes each mood into Apple Health's State of Mind store (valence mapped from the 5-point scale).
- **[`HealthService.swift`](Shared/Services/HealthService.swift)** — Reads activity, exercise time, heart rate, HRV, and sleep and folds them into the Insights prompt for mood↔body correlation.
- **Why it sells:** Reflect becomes the primary State-of-Mind logger for users already bought into Apple Health.
### 13. WeatherKit correlation
- **[`WeatherManager.swift`](Shared/Services/WeatherManager.swift)** — Pulls current conditions from WeatherKit using a one-shot location from [`LocationManager.swift`](Shared/Services/LocationManager.swift) (10-min cache, 15 s timeout) and attaches a [`WeatherData`](Shared/Models/WeatherData.swift) snapshot to the entry.
- **Retry:** failures queue for [`BGTask.runWeatherRetryTask`](Shared/BGTask.swift) so weather fills in overnight.
### 14. Photos on entries
- **[`PhotoManager.swift`](Shared/Services/PhotoManager.swift)** — JPEG at 0.8, stored in the App Group container, 200×200 thumbnail cached for list rendering.
- **Picker:** [`PhotoPickerView.swift`](Shared/Views/PhotoPickerView.swift) / [`ImagePickerGridView.swift`](Shared/Views/ImagePickerGridView.swift).
### 15. Face ID / Touch ID lock
- **[`BiometricAuthManager.swift`](Shared/Services/BiometricAuthManager.swift)** — Detects Face ID / Touch ID / Optic ID, gracefully falls back to the device passcode, and gates the whole app behind a biometric unlock if the user opts in.
### 16. Sharing templates
- **[`Shared/Views/Sharing/`](Shared/Views/Sharing)** — The sharing flow.
- **[`Shared/Views/SharingTemplates/`](Shared/Views/SharingTemplates)** — Visual templates (monthly recap, streak brag, mood pie).
- **[`SharingScreenshotExporter.swift`](Shared/Services/SharingScreenshotExporter.swift)** — Debug tool that renders every template for marketing.
- **Why it sells:** Shareable cards are a zero-cost organic-growth channel — each user who shares is a free ad.
### 17. Customization
[`Shared/Views/CustomizeView/`](Shared/Views/CustomizeView) lets users pick:
| Axis | Protocol | Options |
|---|---|---|
| Color set | [`MoodTintable`](Shared/Models/MoodTintable.swift) | Default, Pastel, Neon, Ocean, Forest, Sunset |
| Icon / emoji | [`MoodImagable`](Shared/Models/MoodImagable.swift) | FontAwesome, Emoji, Hand Emoji |
| Shape | [`Shapes.swift`](Shared/Models/Shapes.swift) | Circle, Square, Diamond, Rounded |
| Theme | [`Theme.swift`](Shared/Models/Theme.swift) | System / Light / Dark / iFeel gradient |
| Personality | [`PersonalityPackable`](Shared/Models/PersonalityPackable.swift) | Default, Coach, Zen, Best Friend, Data Analyst |
| App icon | [`Shared/Views/CustomIcon/`](Shared/Views/CustomIcon) | Multiple home-screen icons |
### 18. Notifications + personality packs
- **[`LocalNotification.swift`](Shared/LocalNotification.swift)** — Daily reminder with inline mood buttons; notification copy is written by the selected [`PersonalityPackable`](Shared/Models/PersonalityPackable.swift) so the "Nice" pack asks gently and the "Rude" pack heckles you.
- **[`AppDelegate.swift`](Shared/AppDelegate.swift)** — Handles the action buttons on the notification to route the vote through `MoodLogger`.
### 19. Export / Import
- **[`ExportService.swift`](Shared/Services/ExportService.swift)** — CSV, JSON, and PDF export of the full entry history with notes, weather, reflections, photos, and entry source preserved.
- **Why it sells:** No lock-in. Users know their data can leave, which makes them trust it going in.
### 20. Subscriptions
- **[`IAPManager.swift`](Shared/IAPManager.swift)** — StoreKit 2, monthly (`com.88oakapps.reflect.IAP.subscriptions.monthly`) and yearly, 30-day free trial counted from `firstLaunchDate`, paywall UI in [`ReflectSubscriptionStoreView.swift`](Shared/Views/ReflectSubscriptionStoreView.swift).
- **Gated behind paywall:** AI Insights, Weekly Digest, AI Reports, Guided Reflection AI feedback, HealthKit correlation insights, Apple Watch premium complications. Core logging + history are free forever.
---
## Guided Reflection — deep dive
> **Source files:**
> — Model: [`Shared/Models/GuidedReflection.swift`](Shared/Models/GuidedReflection.swift)
> — Distortion detector: [`Shared/Services/CognitiveDistortionDetector.swift`](Shared/Services/CognitiveDistortionDetector.swift)
> — AI feedback: [`Shared/Services/FoundationModelsReflectionService.swift`](Shared/Services/FoundationModelsReflectionService.swift)
> — Feedback model: [`Shared/Models/AIReflectionFeedback.swift`](Shared/Models/AIReflectionFeedback.swift)
> — UI: [`Shared/Views/GuidedReflectionView.swift`](Shared/Views/GuidedReflectionView.swift)
> — Feedback UI: [`Shared/Views/ReflectionFeedbackView.swift`](Shared/Views/ReflectionFeedbackView.swift)
> — Info sheet: [`Shared/Views/GuidedReflectionInfoView.swift`](Shared/Views/GuidedReflectionInfoView.swift)
> — Plan doc: [`docs/guided-reflection-cbt-plan.md`](docs/guided-reflection-cbt-plan.md)
> — Flowchart: [`docs/guided-reflection-flowchart.html`](docs/guided-reflection-flowchart.html)
### Why this exists
Most mood trackers stop at logging. Reflect's reflection flow is a short, mood-adaptive, **CBT-aligned** guided exercise. The goal is not to make the user journal more — it's to run a known therapeutic mechanism that has evidence for changing the relationship with a thought.
Three mood tiers → three different evidence-based frameworks:
| Mood | Framework | Mechanism | Question count |
|---|---|---|---|
| **Great / Good** | Behavioral Activation (BA) | Savor + plan to repeat | 3 |
| **Average** | ACT Cognitive Defusion | Notice thought, loosen its grip, re-orient to values | 4 |
| **Bad / Horrible** | CBT Thought Record | Identify situation, surface automatic thought, check for distortion, examine evidence, generate a balanced reframe | 5 |
These are routed from the mood value via [`MoodCategory(from:)`](Shared/Models/GuidedReflection.swift) and rendered as a stepped sheet from [`GuidedReflectionView.swift`](Shared/Views/GuidedReflectionView.swift).
### The Socratic template system
The defining feature of Socratic questioning in CBT is that **each question builds on the user's previous answer**. Static question lists are not Socratic. We implemented this with [`QuestionTemplate`](Shared/Models/GuidedReflection.swift) — each question has a `text` with an optional `%@` placeholder and a `placeholderRef: Int?` pointing at which earlier question's answer to inject.
Example (negative path):
```
Q1: "What happened today that affected your mood?"
→ user: "My boss criticized my presentation in front of the team"
Q2: "What thought kept coming back about 'My boss criticized my presentation…'?"
→ user: "I'm not cut out for this job"
Q3 (distortion-specific, see below): "Is 'I'm not cut out for this job' something you are, or something you did?"
Q4 (evidence): "What evidence supports 'I'm not cut out for this job', and what challenges it?"
Q5 (reframe): "Looking at 'I'm not cut out for this job' again — what's a more balanced way to see it?"
```
Injections are truncated to a sentence boundary or 60 characters by [`GuidedReflection.truncatedForInjection`](Shared/Models/GuidedReflection.swift) so long answers never break grammar. Templates are fully localized — each language controls its own `%@` position so grammar stays natural.
### Cognitive distortion detection
[`CognitiveDistortionDetector.swift`](Shared/Services/CognitiveDistortionDetector.swift) classifies the Q2 answer into one of seven types using localized keyword lists (deterministic, offline, privacy-preserving — **not ML**). This was a deliberate product choice: rule-based is inspectable, predictable, and works with zero latency.
| Distortion | Example phrasing | Q3 reframe prompt |
|---|---|---|
| `overgeneralization` | "always", "never", "everyone" | "Can you think of one counter-example to '%@'?" |
| `shouldStatement` | "should", "must", "have to" | "Where did the rule 'I should …' come from? Is it still serving you?" |
| `labeling` | "I am [trait]" | "Is '%@' something you *are*, or something you *did*?" |
| `personalization` | "my fault", "because of me" | "What other factors, besides you, contributed to this?" |
| `catastrophizing` | "ruined", "can't recover" | "What's the worst case? What's the most likely case?" |
| `mindReading` | "thinks I'm", "hates me" | "What evidence do you have for that interpretation?" |
| `unknown` | (no match) | Falls back to the generic perspective check |
Keywords live in [`Localizable.xcstrings`](Reflect/Localizable.xcstrings) under `distortion_*_keywords` keys so each locale tunes its own detection rules. Priority order is specific → general so that "I always ruin everything" classifies as catastrophizing first, overgeneralization second.
### Evidence examination
The negative path explicitly inserts a dedicated evidence step (Q4) — supporting and contradicting evidence both. This is the single most load-bearing step in a real CBT Thought Record and was previously missing.
### Intensity tracking
[`GuidedReflection`](Shared/Models/GuidedReflection.swift) carries optional `preIntensity` and `postIntensity` (010). CBT literature emphasizes measuring the emotion's intensity before and after the thought work — the delta is the efficacy signal. The AI feedback stage references the shift ("you moved from an 8 to a 5") when it narrates the reflection.
### AI feedback stage
On completion [`FoundationModelsReflectionService`](Shared/Services/FoundationModelsReflectionService.swift) generates an [`AIReflectionFeedback`](Shared/Models/AIReflectionFeedback.swift) with three slots — **affirmation**, **observation**, **takeaway** — rendered by [`ReflectionFeedbackView.swift`](Shared/Views/ReflectionFeedbackView.swift). Tone is driven by the user's selected [`PersonalityPackable`](Shared/Models/PersonalityPackable.swift) (Coach / Zen / Best Friend / Data Analyst / Default). This is gated behind the subscription + iOS 26 Apple Intelligence — when unavailable, the reflection still saves normally and the feedback view degrades gracefully.
### Back-compat
Old reflections saved with 4 negative-path responses (pre-evidence step) still decode and still count as complete. [`GuidedReflection.isComplete`](Shared/Models/GuidedReflection.swift) detects the legacy shape so users never lose completed work across updates.
### Full flow diagram
Open [`docs/guided-reflection-flowchart.html`](docs/guided-reflection-flowchart.html) in a browser for the visual flow. The plan doc in [`docs/guided-reflection-cbt-plan.md`](docs/guided-reflection-cbt-plan.md) walks through the phased rationale — phase 1 (templates + intensity), phase 2 (distortion detection + evidence), phase 3 (AI-generated final question).
---
## Architecture
**Pattern:** MVVM + SwiftUI, singletons for cross-cutting concerns.
**Entry point:** [`Shared/ReflectApp.swift`](Shared/ReflectApp.swift)
### Layers
```
┌─────────────────────────────────────────────────────────────────┐
│ Views (SwiftUI) Shared/Views/ │
│ ViewModels (@MainActor) colocated with views │
├─────────────────────────────────────────────────────────────────┤
│ Services (singletons) Shared/Services/ │
│ MoodLogger AnalyticsManager IAPManager │
│ HealthKitManager WeatherManager LocationManager │
│ FoundationModels{Insight,Digest,Reflection,Tag}Service │
│ CognitiveDistortionDetector │
├─────────────────────────────────────────────────────────────────┤
│ Persistence Shared/Persisence/ │
│ DataController (GET/ADD/UPDATE/DELETE) │
│ SharedModelContainer — SwiftData + CloudKit + App Group │
│ ExtensionDataProvider — widget/watch read path │
├─────────────────────────────────────────────────────────────────┤
│ Models Shared/Models/ │
│ MoodEntryModel (@Model) Mood GuidedReflection │
│ AIInsight AIWeeklyDigest AIReflectionFeedback AIReport │
└─────────────────────────────────────────────────────────────────┘
```
### Data flow for a single mood log
```
tap → DayViewViewModel.add()
└─► MoodLogger.shared.logMood() ← ALL entry points converge here
├─► DataController.shared.add() — SwiftData insert + save
├─► HealthKitManager.write() — State of Mind
├─► streak recompute
├─► WidgetCenter.reloadAllTimelines()
├─► WatchConnectivityManager.nudge()
├─► LiveActivityScheduler.update()
├─► TipKit parameter update
└─► AnalyticsManager.shared.track()
```
Widgets and the watch bypass `MoodLogger` when the main app isn't running and write through `ExtensionDataProvider`; `MoodLogger.applySideEffects()` catches up on next launch. Detailed rules: [`CLAUDE.md`](CLAUDE.md).
---
## Data model
Primary entity: [`MoodEntryModel`](Shared/Models/MoodEntryModel.swift) — a SwiftData `@Model` with:
| Field | Type | Notes |
|---|---|---|
| `moodValue` | Int | 04 (Horrible → Great), 5 = missing, 6 = placeholder |
| `forDate` | Date | The *logical* day being rated |
| `timestamp` | Date | When the entry was written |
| `weekDay` | Int | 17, denormalized for fast filtering |
| `entryType` | Int | listView / widget / watch / shortcut / filledInMissing / notification / header / siri / controlCenter / liveActivity |
| `notes` | String? | Free text |
| `photoID` | String? | Path into App Group container |
| `weatherJSON` | String? | [`WeatherData`](Shared/Models/WeatherData.swift) |
| `reflectionJSON` | String? | [`GuidedReflection`](Shared/Models/GuidedReflection.swift) |
| `tagsJSON` | String? | [`AIEntryTags`](Shared/Models/AIEntryTags.swift) |
All fields have defaults — required by CloudKit. Sync is automatic via SwiftData's built-in CloudKit integration.
---
## Directory map
| Path | Contents |
|---|---|
| [`Shared/`](Shared) | All cross-platform code |
| [`Shared/Models/`](Shared/Models) | Domain types, SwiftData models, AI schemas |
| [`Shared/Views/`](Shared/Views) | SwiftUI views, grouped by feature |
| [`Shared/Services/`](Shared/Services) | Singletons for AI, HealthKit, Weather, Export, etc. |
| [`Shared/Persisence/`](Shared/Persisence) | SwiftData layer *(note: directory name has a typo — intentional, for historic reasons)* |
| [`Shared/Onboarding/`](Shared/Onboarding) | First-launch flow |
| [`Shared/Utilities/`](Shared/Utilities) | Small helpers |
| [`ReflectWidget/`](ReflectWidget) | Widget + Control Center + Live Activity extension |
| [`Reflect Watch App/`](Reflect%20Watch%20App) | watchOS app + complications |
| [`Reflect/`](Reflect) | iOS app target assets, Info.plist, Localizable.xcstrings |
| [`Tests iOS/`](Tests%20iOS) | XCUITest suites — see [`docs/XCUITest-Authoring.md`](docs/XCUITest-Authoring.md) |
| [`ReflectTests/`](ReflectTests) | XCTest unit tests |
| [`Tests macOS/`](Tests%20macOS) | macOS target tests |
| [`landing_page/`](landing_page) | Marketing site |
| [`docs/`](docs) | Design docs, ASO, QA plan, CBT plan, competitors |
| [`scripts/`](scripts) | Build/dev utilities |
| [`ads/`](ads) | Marketing creative |
---
## Build, run, test
```bash
# Build
xcodebuild -project Reflect.xcodeproj \
-scheme "Reflect (iOS)" \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
# Run all tests
xcodebuild -project Reflect.xcodeproj \
-scheme "Reflect (iOS)" \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' test
# Run one UI suite
xcodebuild -project Reflect.xcodeproj \
-scheme "Reflect (iOS)" \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing:"Tests iOS/Tests_iOS" test
```
UI test conventions are mandatory — see [`docs/XCUITest-Authoring.md`](docs/XCUITest-Authoring.md), [`Tests iOS/Helpers/BaseUITestCase.swift`](Tests%20iOS/Helpers/BaseUITestCase.swift), and [`Shared/AccessibilityIdentifiers.swift`](Shared/AccessibilityIdentifiers.swift).
---
## Localization
Format: **String Catalog** at [`Reflect/Localizable.xcstrings`](Reflect/Localizable.xcstrings).
Shipping languages: English · German · Spanish · French · Japanese · Korean · Portuguese (Brazil).
All user-facing strings use `String(localized:)`. "Reflect" is a brand name — untranslated. Distortion-detection keywords are per-locale (see [Cognitive distortion detection](#cognitive-distortion-detection)).
---
## Privacy & security
- **No account, no backend, no third-party server** ever sees mood data. Data lives in SwiftData, synced through the user's own private CloudKit database.
- **AI is 100% on-device** — Foundation Models runs locally; nothing is sent to Anthropic, OpenAI, Apple servers, or us.
- **Analytics** (PostHog via [`AnalyticsManager`](Shared/Analytics.swift)) captures UI events, never mood content, and the user can opt out in settings.
- **Optional biometric lock** ([`BiometricAuthManager`](Shared/Services/BiometricAuthManager.swift)) gates the app behind Face ID / Touch ID / Optic ID.
- **HealthKit** read/write is permission-gated and only triggered on explicit user opt-in.
---
## Configuration reference
| Setting | Value |
|---|---|
| Bundle ID (iOS) | `com.88oakapps.reflect` |
| App Group (prod) | `group.com.88oakapps.reflect` |
| App Group (debug) | `group.com.88oakapps.reflect.debug` |
| CloudKit (prod) | `iCloud.com.88oakapps.reflect` |
| CloudKit (debug) | `iCloud.com.88oakapps.reflect.debug` |
| Subscription group | `21951685` |
| Monthly product ID | `com.88oakapps.reflect.IAP.subscriptions.monthly` |
| Yearly product ID | `com.88oakapps.reflect.IAP.subscriptions.yearly` |
| Free trial | 30 days from `firstLaunchDate` |
| URL scheme | `reflect://` (e.g. `reflect://subscribe`) |
| BGTask — missing dates | `com.88oakapps.reflect.dbUpdateMissing` |
| BGTask — weather retry | `com.88oakapps.reflect.weatherRetry` |
| BGTask — weekly digest | `com.88oakapps.reflect.weeklyDigest` |
| Logger subsystem | `com.88oakapps.reflect` |
---
*For deeper architectural rules, concurrency constraints, data-access invariants, and edge-case gotchas, see [`CLAUDE.md`](CLAUDE.md). For ASO strategy and screenshot plans see [`docs/`](docs).*

View File

@@ -22,8 +22,9 @@ struct ContentView: View {
// Show voting UI
VStack(spacing: 8) {
Text("How do you feel?")
.font(.system(size: 16, weight: .medium))
.font(.headline)
.foregroundColor(.secondary)
.accessibilityAddTraits(.isHeader)
// Top row: Great, Good, Average
HStack(spacing: 8) {
@@ -87,11 +88,14 @@ struct AlreadyRatedView: View {
VStack(spacing: 12) {
Text(mood.watchEmoji)
.font(.system(size: 50))
.accessibilityHidden(true)
Text("Logged!")
.font(.system(size: 18, weight: .semibold))
.font(.title3.weight(.semibold))
.foregroundColor(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(mood.strValue) mood logged"))
}
}
@@ -104,13 +108,16 @@ struct MoodButton: View {
var body: some View {
Button(action: action) {
Text(mood.watchEmoji)
.font(.system(size: 28))
.font(.title2)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(mood.watchColor.opacity(0.3))
.cornerRadius(12)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Watch.moodButton(mood.strValue))
.accessibilityLabel(String(localized: "Log \(mood.strValue) mood"))
.accessibilityHint(String(localized: "Double tap to log your mood as \(mood.strValue)"))
}
}

View File

@@ -6,6 +6,7 @@
<array>
<string>com.88oakapps.reflect.dbUpdateMissing</string>
<string>com.88oakapps.reflect.weatherRetry</string>
<string>com.88oakapps.reflect.weeklyDigest</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Reflect uses your location to show weather details for your mood entries.</string>

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,12 @@ struct MoodStreakLiveActivity: Widget {
HStack(spacing: 8) {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.accessibilityHidden(true)
Text("\(context.state.currentStreak)")
.font(.title2.bold())
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
}
DynamicIslandExpandedRegion(.trailing) {
@@ -34,6 +37,7 @@ struct MoodStreakLiveActivity: Widget {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title2)
.accessibilityLabel(String(localized: "Mood logged today"))
} else {
Text("Log now")
.font(.caption)
@@ -56,20 +60,25 @@ struct MoodStreakLiveActivity: Widget {
Circle()
.fill(Color(hex: context.state.lastMoodColor))
.frame(width: 20, height: 20)
.accessibilityHidden(true)
Text("Today: \(context.state.lastMoodLogged)")
.font(.subheadline)
}
.accessibilityElement(children: .combine)
}
}
} compactLeading: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.accessibilityLabel(String(localized: "Streak"))
} compactTrailing: {
Text("\(context.state.currentStreak)")
.font(.caption.bold())
.accessibilityLabel(String(localized: "\(context.state.currentStreak) days"))
} minimal: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.accessibilityLabel(String(localized: "Mood streak"))
}
}
}
@@ -87,12 +96,15 @@ struct MoodStreakLockScreenView: View {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
.accessibilityHidden(true)
Text("\(context.state.currentStreak)")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
Divider()
.frame(height: 50)
@@ -104,6 +116,7 @@ struct MoodStreakLockScreenView: View {
Circle()
.fill(Color(hex: context.state.lastMoodColor))
.frame(width: 24, height: 24)
.accessibilityHidden(true)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
@@ -112,6 +125,7 @@ struct MoodStreakLockScreenView: View {
.font(.headline)
}
}
.accessibilityElement(children: .combine)
} else {
VStack(alignment: .leading) {
Text(context.state.currentStreak > 0 ? "Don't break your streak!" : "Start your streak!")

View File

@@ -82,6 +82,8 @@ struct SmallWidgetView: View {
return f
}
private var isSampleData: Bool
init(entry: Provider.Entry) {
self.entry = entry
let realData = TimeLineCreator.createViews(daysBack: 2)
@@ -89,6 +91,7 @@ struct SmallWidgetView: View {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
isSampleData = !hasRealData
todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first
}
@@ -98,6 +101,13 @@ struct SmallWidgetView: View {
VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else if let today = todayView {
VStack(spacing: 0) {
if isSampleData {
Text(String(localized: "Log your first mood!"))
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.top, 8)
}
Spacer()
// Large mood icon
@@ -152,6 +162,8 @@ struct MediumWidgetView: View {
return f
}
private var isSampleData: Bool
init(entry: Provider.Entry) {
self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
@@ -159,6 +171,7 @@ struct MediumWidgetView: View {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
isSampleData = !hasRealData
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
}
@@ -183,11 +196,19 @@ struct MediumWidgetView: View {
Text("Last 5 Days")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text("·")
.foregroundStyle(.secondary)
Text(headerDateRange)
.font(.caption)
.foregroundStyle(.secondary)
if isSampleData {
Text("·")
.foregroundStyle(.secondary)
Text(String(localized: "Log your first mood!"))
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("·")
.foregroundStyle(.secondary)
Text(headerDateRange)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 14)
@@ -264,6 +285,8 @@ struct LargeWidgetView: View {
!entry.hasVotedToday
}
private var isSampleData: Bool
init(entry: Provider.Entry) {
self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
@@ -271,6 +294,7 @@ struct LargeWidgetView: View {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
isSampleData = !hasRealData
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
}
@@ -301,7 +325,7 @@ struct LargeWidgetView: View {
Text("Last 10 Days")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(headerDateRange)
Text(isSampleData ? String(localized: "Log your first mood!") : headerDateRange)
.font(.caption2)
.foregroundStyle(.secondary)
}

View File

@@ -158,10 +158,13 @@ struct VotedStatsView: View {
Circle()
.fill(moodTint.color(forMood: mood))
.frame(width: 8, height: 8)
.accessibilityHidden(true)
Text("\(count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(count) \(mood.strValue)")
}
}
}
@@ -214,6 +217,7 @@ struct NonSubscriberView: View {
}
.accessibilityLabel(String(localized: "Track Your Mood"))
.accessibilityHint(String(localized: "Tap to open app and subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}

View File

@@ -58,8 +58,8 @@ struct VotingView: View {
VStack(spacing: 0) {
// Top 50%: Text left-aligned, vertically centered
HStack {
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.system(size: 20, weight: .semibold))
Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.lineLimit(2)
@@ -93,6 +93,7 @@ struct VotingView: View {
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else {
Link(destination: URL(string: "reflect://subscribe")!) {
moodIcon(for: mood, size: size)
@@ -100,6 +101,7 @@ struct VotingView: View {
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}
@@ -119,12 +121,14 @@ struct VotingView: View {
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else {
Link(destination: URL(string: "reflect://subscribe")!) {
content
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}
@@ -155,8 +159,8 @@ struct LargeVotingView: View {
GeometryReader { geo in
VStack(spacing: 0) {
// Top 33%: Title centered
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.system(size: 24, weight: .semibold))
Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
.font(.title2.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
@@ -196,12 +200,14 @@ struct LargeVotingView: View {
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else {
Link(destination: URL(string: "reflect://subscribe")!) {
moodButtonContent(for: mood)
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}
@@ -261,12 +267,14 @@ struct InlineVotingView: View {
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else {
Link(destination: URL(string: "reflect://subscribe")!) {
moodIcon(for: mood)
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}

View File

@@ -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"
@@ -120,6 +135,45 @@ enum AccessibilityID {
static let reminderTimePicker = "settings_reminder_time_picker"
static let reminderSaveButton = "settings_reminder_save"
static let reminderCancelButton = "settings_reminder_cancel"
static let reminderTimeButton = "settings_reminder_time"
static let changeTrialDateButton = "settings_change_trial_date"
static let trialDatePickerDoneButton = "settings_trial_date_done"
static let trialDatePicker = "settings_trial_date_picker"
static let paywallPreviewButton = "settings_paywall_preview"
static let tipsPreviewButton = "settings_tips_preview"
static let testNotificationsButton = "settings_test_notifications"
static let exportWidgetsButton = "settings_export_widgets"
static let exportVotingLayoutsButton = "settings_export_voting_layouts"
static let exportWatchViewsButton = "settings_export_watch_views"
static let exportInsightsButton = "settings_export_insights"
static let generateScreenshotsButton = "settings_generate_screenshots"
static let addTestDataButton = "settings_add_test_data"
static let deleteHealthKitButton = "settings_delete_health_kit"
static let locationAlertOpenSettingsButton = "settings_location_open_settings"
static let locationAlertCancelButton = "settings_location_cancel"
static let fontAwesomeLink = "settings_font_awesome_link"
static let chartsLink = "settings_charts_link"
static let exportDataButton = "settings_export_data"
static let closeButton = "settings_close"
static let resetLaunchDateButton = "settings_reset_launch_date"
static let fixWeekdayButton = "settings_fix_weekday"
static let whyBackgroundModeButton = "settings_why_bg_mode"
static let exportLegacyButton = "settings_export_legacy"
static let importButton = "settings_import"
static let randomIconsButton = "settings_random_icons"
static let doneButton = "settings_done"
static let specialThanksButton = "settings_special_thanks"
}
// MARK: - TipModal
enum TipModal {
static let dismissButton = "tip_modal_dismiss"
static let resetTipsButton = "tip_modal_reset_tips"
static let tipsEnabledToggle = "tip_modal_tips_enabled"
static let doneButton = "tip_modal_done"
static func tipPreviewButton(_ index: Int) -> String {
"tip_modal_preview_\(index)"
}
}
// MARK: - Customize
@@ -129,6 +183,7 @@ enum AccessibilityID {
static let appThemePickerDoneButton = "apptheme_picker_done"
static let appThemePreviewCancelButton = "apptheme_preview_cancel"
static let appThemePreviewApplyButton = "apptheme_preview_apply"
static let widgetHowToLink = "customize_widget_how_to_link"
static func themeButton(_ name: String) -> String {
"customize_theme_\(name.lowercased())"
}
@@ -147,6 +202,31 @@ enum AccessibilityID {
static func appThemeCard(_ name: String) -> String {
"apptheme_card_\(name.lowercased())"
}
static func customWidget(_ index: Int) -> String {
"customize_widget_\(index)"
}
static let customWidgetAdd = "customize_widget_add"
static func shapeOption(_ name: String) -> String {
"customize_shape_\(name.lowercased())"
}
static let shapeRefresh = "customize_shape_refresh"
static func imagePackOption(_ name: String) -> String {
"customize_imagepack_option_\(name.lowercased())"
}
static func personalityPackOption(_ name: String) -> String {
"customize_personalitypack_option_\(name.lowercased())"
}
static func celebrationAnimationButton(_ name: String) -> String {
"customize_celebration_\(name.lowercased())"
}
static let manageSubscriptionButton = "customize_manage_subscription"
static let unlockPremiumButton = "customize_unlock_premium"
static func dayFilterButton(_ day: String) -> String {
"customize_day_filter_\(day.lowercased())"
}
static func iconButton(_ name: String) -> String {
"customize_icon_\(name.lowercased())"
}
}
// MARK: - Paywall
@@ -173,21 +253,32 @@ enum AccessibilityID {
static let monthSection = "insights_month_section"
static let yearSection = "insights_year_section"
static let allTimeSection = "insights_all_time_section"
static let expandCollapseButton = "insights_expand_collapse"
}
// MARK: - Month View
enum MonthView {
static let grid = "month_grid"
static let shareButton = "month_share_button"
static let statsToggleButton = "month_stats_toggle"
static let settingsButton = "month_settings_button"
static func dayCell(dateString: String) -> String {
"month_day_cell_\(dateString)"
}
static let debugDemoToggle = "month_debug_demo_toggle"
}
// MARK: - Month Detail
enum MonthDetail {
static let shareButton = "month_detail_share"
static let deleteButton = "month_detail_delete"
static let cancelButton = "month_detail_cancel"
static func moodButton(_ mood: String) -> String {
"month_detail_mood_\(mood.lowercased())"
}
static func entryCell(_ dateString: String) -> String {
"month_detail_entry_\(dateString)"
}
}
// MARK: - Year View
@@ -198,6 +289,7 @@ enum AccessibilityID {
static let statsSection = "year_stats_section"
static func cardHeader(year: Int) -> String { "year_card_header_\(year)" }
static let shareButton = "year_share_button"
static let debugDemoToggle = "year_debug_demo_toggle"
}
// MARK: - Onboarding
@@ -213,12 +305,23 @@ enum AccessibilityID {
static let subscribeButton = "onboarding_subscribe_button"
static let skipButton = "onboarding_skip_button"
static let nextButton = "onboarding_next_button"
static let timePicker = "onboarding_time_picker"
static let wrapupContinue = "onboarding_wrapup_continue"
static let titleOptionButton = "onboarding_title_option"
static func styleThemeButton(_ name: String) -> String {
"onboarding_style_theme_\(name.lowercased())"
}
}
// MARK: - Reports
enum Reports {
static let segmentedPicker = "reports_segmented_picker"
static let dateRangePicker = "reports_date_range_picker"
static let previousMonthButton = "reports_previous_month"
static let nextMonthButton = "reports_next_month"
static func dayCell(dateString: String) -> String {
"reports_day_cell_\(dateString)"
}
static let quickSummaryButton = "reports_quick_summary_button"
static let detailedReportButton = "reports_detailed_report_button"
static let generateButton = "reports_generate_button"
@@ -229,6 +332,8 @@ enum AccessibilityID {
static let minimumEntriesWarning = "reports_minimum_entries_warning"
static let exportDataButton = "reports_export_data_button"
static let retryButton = "reports_retry_button"
static let privacyShareButton = "reports_privacy_share"
static let privacyCancelButton = "reports_privacy_cancel"
}
// MARK: - Purchase / Subscription
@@ -239,6 +344,11 @@ enum AccessibilityID {
static let subscribeButton = "purchase_subscribe"
}
// MARK: - Subscription Store
enum SubscriptionStore {
static let closeButton = "subscription_store_close"
}
// MARK: - IAP Warning
enum IAPWarning {
static let subscribeButton = "iap_warning_subscribe"
@@ -249,6 +359,7 @@ enum AccessibilityID {
static let unlockButton = "lock_screen_unlock"
static let tryAgainButton = "lock_screen_try_again"
static let cancelButton = "lock_screen_cancel"
static let passcodeUnlockButton = "lock_screen_passcode_unlock"
static func passcodeButton(_ digit: Int) -> String {
"lock_screen_passcode_\(digit)"
}
@@ -260,6 +371,135 @@ enum AccessibilityID {
static let dismissArea = "full_screen_photo_dismiss"
}
// MARK: - Export
enum Export {
static let cancelButton = "export_cancel"
static let exportButton = "export_export"
static let alertOKButton = "export_alert_ok"
static func formatButton(_ format: String) -> String {
"export_format_\(format.lowercased())"
}
static func rangeButton(_ range: String) -> String {
"export_range_\(range.lowercased())"
}
}
// MARK: - Photo Picker
enum PhotoPicker {
static let cameraButton = "photo_picker_camera"
static let cancelButton = "photo_picker_cancel"
static let closeButton = "photo_picker_close"
static let shareButton = "photo_picker_share"
static let deleteButton = "photo_picker_delete"
static let deleteConfirmButton = "photo_picker_delete_confirm"
static let deleteCancelButton = "photo_picker_delete_cancel"
static let photosPicker = "photo_picker_library"
static let photoImage = "photo_picker_image"
static let menuButton = "photo_picker_menu"
}
// MARK: - Sharing
enum Sharing {
static let exitButton = "sharing_exit"
static let shareButton = "sharing_share"
static func moodMenuButton(_ mood: String) -> String {
"sharing_mood_menu_\(mood.lowercased())"
}
static let moodMenu = "sharing_mood_menu"
static func templateButton(_ description: String) -> String {
"sharing_template_\(description.lowercased().replacingOccurrences(of: " ", with: "_"))"
}
}
// MARK: - Sharing Templates
enum SharingTemplate {
static let dismissButton = "sharing_template_dismiss"
static let shareButton = "sharing_template_share"
static let moodMenu = "sharing_template_mood_menu"
static func moodMenuButton(_ mood: String) -> String {
"sharing_template_mood_menu_\(mood.lowercased())"
}
}
// MARK: - Custom Widget
enum CustomWidget {
static func colorPicker(_ name: String) -> String {
"custom_widget_color_\(name.lowercased())"
}
static let leftEyeButton = "custom_widget_left_eye"
static let rightEyeButton = "custom_widget_right_eye"
static let mouthButton = "custom_widget_mouth"
static func backgroundOption(_ index: Int) -> String {
"custom_widget_bg_\(index)"
}
static let randomBackgroundButton = "custom_widget_random_bg"
static let shuffleButton = "custom_widget_shuffle"
static let saveButton = "custom_widget_save"
static let useButton = "custom_widget_use"
static let deleteButton = "custom_widget_delete"
static func imageOption(_ name: String) -> String {
"custom_widget_image_\(name.lowercased())"
}
}
// MARK: - Debug / Preview
enum Debug {
static let animationDoneButton = "debug_animation_done"
static func animationCard(_ name: String) -> String {
"debug_animation_\(name.lowercased())"
}
static func debugMoodButton(_ mood: String) -> String {
"debug_mood_\(mood.lowercased())"
}
static let paywallPreviewDoneButton = "debug_paywall_done"
static let viewFullPaywallButton = "debug_view_full_paywall"
static func paywallStyleOption(_ name: String) -> String {
"debug_paywall_style_\(name.lowercased())"
}
static let liveActivityResetButton = "debug_live_activity_reset"
static let liveActivityToggleButton = "debug_live_activity_toggle"
static let liveActivityRecordButton = "debug_live_activity_record"
static let liveActivityDismissButton = "debug_live_activity_dismiss"
static let liveActivityExportButton = "debug_live_activity_export"
}
// MARK: - Sample Entry
enum SampleEntry {
static let refreshButton = "sample_entry_refresh"
}
// MARK: - Switchable View
enum SwitchableView {
static let headerToggle = "switchable_view_header_toggle"
}
// MARK: - Neon Mood Button (voting layout)
enum NeonMoodButton {
static func id(for mood: String) -> String {
"neon_mood_button_\(mood.lowercased())"
}
}
// MARK: - App Alerts
enum AppAlert {
static let storageUnavailableOK = "app_alert_storage_ok"
}
// MARK: - Watch
enum Watch {
static func moodButton(_ mood: String) -> String {
"watch_mood_button_\(mood.lowercased())"
}
}
// MARK: - Widget
enum Widget {
static func voteMoodButton(_ mood: String) -> String {
"widget_vote_mood_\(mood.lowercased())"
}
static let subscribeLink = "widget_subscribe_link"
}
// MARK: - Common
enum Common {
static let lockScreen = "lock_screen"

View File

@@ -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) {
@@ -51,7 +52,68 @@ class BGTask {
do {
try BGTaskScheduler.shared.submit(request)
} catch {
#if DEBUG
print("Could not schedule weather retry: \(error)")
#endif
}
}
@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)")
}
}
@@ -67,7 +129,9 @@ class BGTask {
do {
try BGTaskScheduler.shared.submit(request)
} catch {
#if DEBUG
print("Could not schedule image fetch: \(error)")
#endif
}
}
}

View File

@@ -134,7 +134,6 @@ extension Color {
}
extension Color: @retroactive RawRepresentable {
// TODO: Sort out alpha
public init?(rawValue: Int) {
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF

View File

@@ -37,13 +37,9 @@ class IAPManager: ObservableObject {
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
/// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription
#if DEBUG
@Published var bypassSubscription: Bool {
didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") }
}
#else
let bypassSubscription = false
#endif
// MARK: - Constants
@@ -140,9 +136,7 @@ class IAPManager: ObservableObject {
// MARK: - Initialization
init() {
#if DEBUG
self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription")
#endif
restoreCachedSubscriptionState()
updateListenerTask = listenForTransactions()
@@ -307,8 +301,16 @@ class IAPManager: ObservableObject {
// Get renewal info
if let product = currentProduct,
let subscription = product.subscription,
let statuses = try? await subscription.status {
let subscription = product.subscription {
let statuses: [Product.SubscriptionInfo.Status]
do {
statuses = try await subscription.status
} catch {
AppLogger.iap.error("Failed to fetch subscription status for \(product.id): \(error)")
// Fallback handled below
state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false)
return true
}
var hadVerifiedStatus = false
for status in statuses {
@@ -365,7 +367,6 @@ class IAPManager: ObservableObject {
return false
}
#if DEBUG
/// Reset subscription state for UI testing. Called after group defaults are cleared
/// so that stale cached state from previous test runs is discarded.
func resetForTesting() {
@@ -382,7 +383,6 @@ class IAPManager: ObservableObject {
updateTrialState()
}
#endif
private func updateTrialState() {
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0

View File

@@ -69,11 +69,15 @@ class LocalNotification {
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: trigger)
UNUserNotificationCenter.current().add(request) { (error : Error?) in
if let theError = error {
#if DEBUG
print(theError.localizedDescription)
#endif
}
}
case .failure(let error):
#if DEBUG
print(error)
#endif
// Todo: show enable this
break
}
@@ -135,7 +139,28 @@ class LocalNotification {
// MARK: - Debug: Send All Personality Pack Notifications
#if DEBUG
// 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)")
}
}
}
/// Sends one notification from each personality pack, staggered over 10 seconds for screenshot
public class func sendAllPersonalityNotificationsForScreenshot() {
let _ = createNotificationCategory()
@@ -173,5 +198,4 @@ class LocalNotification {
}
}
}
#endif
}

View 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]
}

View 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
}

View 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
}
}

View File

@@ -12,7 +12,7 @@ import Foundation
enum MoodCategory: String, Codable {
case positive // great, good 3 questions (Behavioral Activation)
case neutral // average 4 questions (ACT Cognitive Defusion)
case negative // bad, horrible 4 questions (CBT Thought Record)
case negative // bad, horrible 5 questions (CBT Thought Record with evidence step)
init(from mood: Mood) {
switch mood {
@@ -26,7 +26,8 @@ enum MoodCategory: String, Codable {
var questionCount: Int {
switch self {
case .positive: return 3
case .neutral, .negative: return 4
case .neutral: return 4
case .negative: return 5
}
}
@@ -47,6 +48,7 @@ enum MoodCategory: String, Codable {
String(localized: "Situation"),
String(localized: "Automatic Thought"),
String(localized: "Perspective Check"),
String(localized: "Evidence"),
String(localized: "Reframe"),
]
case .neutral:
@@ -66,6 +68,52 @@ enum MoodCategory: String, Codable {
}
}
// MARK: - Cognitive Distortion
/// Detected cognitive distortion type in a user's automatic thought.
/// Used to route the perspective-check question to a distortion-specific reframe.
enum CognitiveDistortion: String, Codable {
case overgeneralization
case shouldStatement
case labeling
case personalization
case catastrophizing
case mindReading
case unknown
}
// MARK: - Question Template
/// A guided reflection question. May contain `%@` placeholders resolved at render time
/// by substituting the answer from a prior question (Socratic back-reference).
struct QuestionTemplate: Equatable {
/// Localized template text may contain a single `%@` format specifier.
let text: String
/// Zero-based index of the question whose answer to inject in place of `%@`.
/// Nil if this template is static (no placeholder).
let placeholderRef: Int?
/// Resolve the template against the provided ordered list of answers.
/// - Parameter answers: Array of (index, answer) pairs where `index` matches `placeholderRef`.
func resolved(with answers: [(index: Int, text: String)]) -> String {
guard let ref = placeholderRef else { return text }
let referenced = answers
.first(where: { $0.index == ref })?
.text
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !referenced.isEmpty, text.contains("%@") else {
// Fallback strip the placeholder marker so we never show a literal "%@".
return text.replacingOccurrences(of: "%@", with: "").trimmingCharacters(in: .whitespaces)
}
let injected = GuidedReflection.truncatedForInjection(referenced)
return String(format: text, injected)
}
}
// MARK: - Question Chips
struct QuestionChips {
@@ -80,24 +128,24 @@ struct QuestionChips {
// MARK: Positive (Great/Good) Behavioral Activation
// Q1: "What did you do today?" no chips (situational)
// Q2: "What thought or moment stands out?" positive feelings to savor
// Q2: "What thought or moment stands out?" memorable moments to savor
case (.positive, 1):
return QuestionChips(
topRow: [
String(localized: "guided_chip_pos_joy"),
String(localized: "guided_chip_pos_gratitude"),
String(localized: "guided_chip_pos_pride"),
String(localized: "guided_chip_pos_contentment"),
String(localized: "guided_chip_pos_love"),
String(localized: "guided_chip_pos_excitement"),
String(localized: "guided_chip_pos_moment_conversation"),
String(localized: "guided_chip_pos_moment_accomplished"),
String(localized: "guided_chip_pos_moment_calm"),
String(localized: "guided_chip_pos_moment_laugh"),
String(localized: "guided_chip_pos_moment_grateful_person"),
String(localized: "guided_chip_pos_moment_small_win"),
],
expanded: [
String(localized: "guided_chip_pos_inspiration"),
String(localized: "guided_chip_pos_amusement"),
String(localized: "guided_chip_pos_serenity"),
String(localized: "guided_chip_pos_relief"),
String(localized: "guided_chip_pos_connection"),
String(localized: "guided_chip_pos_hope"),
String(localized: "guided_chip_pos_moment_beauty"),
String(localized: "guided_chip_pos_moment_connected"),
String(localized: "guided_chip_pos_moment_progress"),
String(localized: "guided_chip_pos_moment_like_myself"),
String(localized: "guided_chip_pos_moment_kindness"),
String(localized: "guided_chip_pos_moment_time_well_spent"),
]
)
@@ -221,22 +269,26 @@ struct QuestionChips {
expanded: []
)
// Q4: "More balanced way to see it?" grounding actions + cognitive shifts
case (.negative, 3):
// Q3 NEW: Evidence no chips (user explores both sides in free text)
// Q4 NEW Q5: "More balanced way to see it?" cognitive reframes first, grounding actions expanded
case (.negative, 4):
return QuestionChips(
topRow: [
String(localized: "guided_chip_neg_act_worst_case"),
String(localized: "guided_chip_neg_act_facts_feelings"),
String(localized: "guided_chip_neg_act_matter_in_week"),
String(localized: "guided_chip_neg_act_got_through"),
String(localized: "guided_chip_neg_ref_one_chapter"),
String(localized: "guided_chip_neg_ref_doing_my_best"),
],
expanded: [
String(localized: "guided_chip_neg_act_talk_someone"),
String(localized: "guided_chip_neg_act_write_it_out"),
String(localized: "guided_chip_neg_act_take_walk"),
String(localized: "guided_chip_neg_act_step_away"),
String(localized: "guided_chip_neg_act_get_rest"),
String(localized: "guided_chip_neg_act_one_small_thing"),
],
expanded: [
String(localized: "guided_chip_neg_act_worst_case"),
String(localized: "guided_chip_neg_act_got_through"),
String(localized: "guided_chip_neg_act_facts_feelings"),
String(localized: "guided_chip_neg_act_matter_in_week"),
]
)
@@ -280,11 +332,72 @@ struct GuidedReflection: Codable, Equatable {
var responses: [Response]
var completedAt: Date?
// MARK: - New Fields (optional for back-compat with older saved reflections)
/// Emotional intensity rating before the reflection (0-10 scale).
var preIntensity: Int?
/// Emotional intensity rating after the reflection (0-10 scale). Measures change.
var postIntensity: Int?
/// Cognitive distortion detected in the automatic-thought response (negative path only).
var detectedDistortion: CognitiveDistortion?
// MARK: - Codable (tolerant of old JSON without new fields)
enum CodingKeys: String, CodingKey {
case moodCategory, responses, completedAt, preIntensity, postIntensity, detectedDistortion
}
init(
moodCategory: MoodCategory,
responses: [Response],
completedAt: Date?,
preIntensity: Int? = nil,
postIntensity: Int? = nil,
detectedDistortion: CognitiveDistortion? = nil
) {
self.moodCategory = moodCategory
self.responses = responses
self.completedAt = completedAt
self.preIntensity = preIntensity
self.postIntensity = postIntensity
self.detectedDistortion = detectedDistortion
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
moodCategory = try container.decode(MoodCategory.self, forKey: .moodCategory)
responses = try container.decode([Response].self, forKey: .responses)
completedAt = try container.decodeIfPresent(Date.self, forKey: .completedAt)
preIntensity = try container.decodeIfPresent(Int.self, forKey: .preIntensity)
postIntensity = try container.decodeIfPresent(Int.self, forKey: .postIntensity)
detectedDistortion = try container.decodeIfPresent(CognitiveDistortion.self, forKey: .detectedDistortion)
}
// MARK: - Computed Properties
/// A reflection is complete when every required question has a non-empty answer.
/// Intensity ratings are optional and do not gate completion.
///
/// Back-compat: old negative reflections saved with 4 responses are still considered
/// complete we detect the old shape and treat it as valid rather than forcing a re-prompt.
var isComplete: Bool {
responses.count == moodCategory.questionCount &&
responses.allSatisfy { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
let expectedCount = moodCategory.questionCount
let legacyNegativeCount = 4 // pre-evidence-step shape
let nonEmpty = responses.filter {
!$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}.count
if responses.count == expectedCount {
return nonEmpty == expectedCount
}
// Legacy negative reflection (pre-evidence-step) still valid.
if moodCategory == .negative && responses.count == legacyNegativeCount {
return nonEmpty == legacyNegativeCount
}
return false
}
var answeredCount: Int {
@@ -299,38 +412,129 @@ struct GuidedReflection: Codable, Equatable {
static func createNew(for mood: Mood) -> GuidedReflection {
let category = MoodCategory(from: mood)
let questionTexts = questions(for: category)
let responses = questionTexts.enumerated().map { index, question in
Response(id: index, question: question, answer: "")
let templates = questionTemplates(for: category)
let responses = templates.enumerated().map { index, template in
// Store the raw template text on creation the view layer will resolve
// and overwrite this with the user-visible text before saving.
Response(id: index, question: template.text, answer: "")
}
return GuidedReflection(moodCategory: category, responses: responses, completedAt: nil)
return GuidedReflection(
moodCategory: category,
responses: responses,
completedAt: nil
)
}
static func questions(for category: MoodCategory) -> [String] {
// MARK: - Question Templates
/// Returns the ordered template list for a mood category. Templates may contain
/// `%@` placeholders that the view layer fills in with prior answers at render time
/// (Socratic back-reference each question builds on the previous one).
static func questionTemplates(for category: MoodCategory) -> [QuestionTemplate] {
switch category {
case .positive:
// Behavioral Activation: situation savor plan
return [
String(localized: "guided_reflection_positive_q1"),
String(localized: "guided_reflection_positive_q2"),
String(localized: "guided_reflection_positive_q3"),
QuestionTemplate(
text: String(localized: "guided_reflection_positive_q1"),
placeholderRef: nil
),
QuestionTemplate(
text: String(localized: "guided_reflection_positive_q2"),
placeholderRef: nil
),
// Q3 references Q2's "moment that stood out" so the plan is specific.
QuestionTemplate(
text: String(localized: "guided_reflection_positive_q3_templated"),
placeholderRef: 1
),
]
case .neutral:
// ACT: awareness thought defusion values
return [
String(localized: "guided_reflection_neutral_q1"),
String(localized: "guided_reflection_neutral_q2"),
String(localized: "guided_reflection_neutral_q3"),
String(localized: "guided_reflection_neutral_q4"),
QuestionTemplate(
text: String(localized: "guided_reflection_neutral_q1"),
placeholderRef: nil
),
// Q2 references the feeling from Q1.
QuestionTemplate(
text: String(localized: "guided_reflection_neutral_q2_templated"),
placeholderRef: 0
),
// Q3 references the thought from Q2 (the thing to defuse from).
QuestionTemplate(
text: String(localized: "guided_reflection_neutral_q3_templated"),
placeholderRef: 1
),
QuestionTemplate(
text: String(localized: "guided_reflection_neutral_q4"),
placeholderRef: nil
),
]
case .negative:
// CBT Thought Record: situation thought perspective evidence reframe
return [
String(localized: "guided_reflection_negative_q1"),
String(localized: "guided_reflection_negative_q2"),
String(localized: "guided_reflection_negative_q3"),
String(localized: "guided_reflection_negative_q4"),
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q1"),
placeholderRef: nil
),
// Q2 references the situation from Q1.
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q2_templated"),
placeholderRef: 0
),
// Q3 is distortion-specific the view layer picks the right template
// based on the detected distortion in Q2. This default is the fallback.
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_templated"),
placeholderRef: 1
),
// Q4 is the new evidence-examination step (core of CBT Thought Record).
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q_evidence_templated"),
placeholderRef: 1
),
// Q5 is the balanced reframe, still referencing the original thought.
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q4_templated"),
placeholderRef: 1
),
]
}
}
/// Legacy accessor returns templates resolved as static strings (no injection).
/// Kept for any callers that want plain text without a response context.
static func questions(for category: MoodCategory) -> [String] {
questionTemplates(for: category).map { $0.text }
}
// MARK: - Answer Injection Helper
/// Truncates a prior answer for injection into a follow-up question template.
/// Prefers breaking at a sentence boundary or word boundary within `maxLength`.
static func truncatedForInjection(_ text: String, maxLength: Int = 60) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count > maxLength else { return trimmed }
let prefix = String(trimmed.prefix(maxLength))
// Prefer a sentence boundary within the window.
let sentenceEnders: [Character] = [".", "!", "?"]
if let lastSentenceEnd = prefix.lastIndex(where: { sentenceEnders.contains($0) }) {
let candidate = String(prefix[..<lastSentenceEnd]).trimmingCharacters(in: .whitespaces)
if candidate.count >= 15 { // Avoid chopping too short.
return candidate + ""
}
}
// Fallback: last word boundary.
if let lastSpace = prefix.lastIndex(of: " ") {
return String(prefix[..<lastSpace]).trimmingCharacters(in: .whitespaces) + ""
}
return prefix + ""
}
// MARK: - JSON Helpers
func encode() -> String? {

View File

@@ -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
}

View File

@@ -6,6 +6,7 @@
//
import Foundation
import os.log
enum VotingLayoutStyle: Int, CaseIterable {
case horizontal = 0 // Current: 5 buttons in a row
@@ -177,6 +178,8 @@ enum DayViewStyle: Int, CaseIterable {
}
}
private let userDefaultsLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", category: "UserDefaults")
class UserDefaultsStore {
enum Keys: String {
case savedOnboardingData
@@ -226,15 +229,18 @@ class UserDefaultsStore {
}
// Decode and cache
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data,
let model = try? JSONDecoder().decode(OnboardingData.self, from: data) {
cachedOnboardingData = model
return model
} else {
let defaultData = OnboardingData()
cachedOnboardingData = defaultData
return defaultData
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data {
do {
let model = try JSONDecoder().decode(OnboardingData.self, from: data)
cachedOnboardingData = model
return model
} catch {
userDefaultsLogger.error("Failed to decode onboarding data: \(error)")
}
}
let defaultData = OnboardingData()
cachedOnboardingData = defaultData
return defaultData
}
/// Invalidate cached onboarding data (call when data might have changed externally)
@@ -251,7 +257,7 @@ class UserDefaultsStore {
let data = try JSONEncoder().encode(onboardingData)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue)
} catch {
print("Error saving onboarding: \(error)")
userDefaultsLogger.error("Failed to encode onboarding data: \(error)")
}
// Re-cache the saved data
@@ -314,28 +320,38 @@ class UserDefaultsStore {
}
static func getCustomWidgets() -> [CustomWidgetModel] {
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data,
let model = try? JSONDecoder().decode([CustomWidgetModel].self, from: data) {
return model
} else {
GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue)
let widget = CustomWidgetModel.randomWidget
widget.isSaved = true
let widgets = [widget]
guard let data = try? JSONEncoder().encode(widgets) else {
return widgets
}
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
if let savedData = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data,
let models = try? JSONDecoder().decode([CustomWidgetModel].self, from: savedData) {
return models.sorted { $0.createdDate < $1.createdDate }
} else {
return widgets
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data {
do {
let model = try JSONDecoder().decode([CustomWidgetModel].self, from: data)
return model
} catch {
userDefaultsLogger.error("Failed to decode custom widgets: \(error)")
}
}
GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue)
let widget = CustomWidgetModel.randomWidget
widget.isSaved = true
let widgets = [widget]
do {
let data = try JSONEncoder().encode(widgets)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
} catch {
userDefaultsLogger.error("Failed to encode default custom widgets: \(error)")
return widgets
}
if let savedData = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data {
do {
let models = try JSONDecoder().decode([CustomWidgetModel].self, from: savedData)
return models.sorted { $0.createdDate < $1.createdDate }
} catch {
userDefaultsLogger.error("Failed to decode saved custom widgets: \(error)")
}
}
return widgets
}
@discardableResult
@@ -366,7 +382,7 @@ class UserDefaultsStore {
let data = try JSONEncoder().encode(existingWidgets)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
} catch {
print("Error saving custom widget: \(error)")
userDefaultsLogger.error("Failed to encode custom widget for save: \(error)")
}
return UserDefaultsStore.getCustomWidgets()
}
@@ -396,7 +412,7 @@ class UserDefaultsStore {
let data = try JSONEncoder().encode(existingWidgets)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
} catch {
print("Error deleting custom widget: \(error)")
userDefaultsLogger.error("Failed to encode custom widgets for delete: \(error)")
}
return UserDefaultsStore.getCustomWidgets()
}
@@ -407,7 +423,7 @@ class UserDefaultsStore {
let model = try JSONDecoder().decode(SavedMoodTint.self, from: data)
return model
} catch {
print(error)
userDefaultsLogger.error("Failed to decode custom mood tint: \(error)")
}
}
return SavedMoodTint()
@@ -428,7 +444,7 @@ class UserDefaultsStore {
let data = try JSONEncoder().encode(customTint)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customMoodTint.rawValue)
} catch {
print("Error saving custom mood tint: \(error)")
userDefaultsLogger.error("Failed to encode custom mood tint: \(error)")
}
return UserDefaultsStore.getCustomMoodTint()
}

View File

@@ -37,7 +37,9 @@ class LiveActivityManager: ObservableObject {
// Start a mood streak Live Activity
func startStreakActivity(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
#if DEBUG
print("Live Activities not enabled")
#endif
return
}
@@ -76,7 +78,9 @@ class LiveActivityManager: ObservableObject {
)
currentActivity = activity
} catch {
#if DEBUG
print("Error starting Live Activity: \(error)")
#endif
}
}
@@ -257,23 +261,31 @@ class LiveActivityScheduler: ObservableObject {
invalidateTimers()
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
#if DEBUG
print("[LiveActivity] Live Activities not enabled by user")
#endif
return
}
let now = Date()
guard let startTime = getStartTime(),
let endTime = getEndTime() else {
#if DEBUG
print("[LiveActivity] No rating time configured - skipping")
#endif
return
}
let hasRated = hasRatedToday()
#if DEBUG
print("[LiveActivity] Schedule check - now: \(now), start: \(startTime), end: \(endTime), hasRated: \(hasRated)")
#endif
// If user has already rated today, don't show activity - schedule for next day
if hasRated {
#if DEBUG
print("[LiveActivity] User already rated today - scheduling for next day")
#endif
scheduleForNextDay()
return
}
@@ -281,7 +293,9 @@ class LiveActivityScheduler: ObservableObject {
// Check if we're within the activity window (rating time to 5 hrs after)
if now >= startTime && now <= endTime {
// Start activity immediately
#if DEBUG
print("[LiveActivity] Within window - starting activity now")
#endif
let streak = calculateStreak()
LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: getTodaysMood(), hasLoggedToday: false)
@@ -289,12 +303,16 @@ class LiveActivityScheduler: ObservableObject {
scheduleEnd(at: endTime)
} else if now < startTime {
// Schedule start for later today
#if DEBUG
print("[LiveActivity] Before window - scheduling start for \(startTime)")
#endif
scheduleStart(at: startTime)
scheduleEnd(at: endTime)
} else {
// Past the window for today, schedule for tomorrow
#if DEBUG
print("[LiveActivity] Past window - scheduling for tomorrow")
#endif
scheduleForNextDay()
}
}

View File

@@ -195,6 +195,7 @@ struct OnboardingThemeCard: View {
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Onboarding.styleThemeButton(theme.name))
}
}

View File

@@ -65,6 +65,7 @@ struct OnboardingTime: View {
.datePickerStyle(.wheel)
.labelsHidden()
.colorScheme(.light)
.accessibilityIdentifier(AccessibilityID.Onboarding.timePicker)
.accessibilityLabel(String(localized: "Reminder time"))
.accessibilityHint(String(localized: "Select when you want to be reminded"))
}

View File

@@ -36,6 +36,7 @@ struct OnboardingTitle: View {
.cornerRadius(10)
})
.buttonStyle(PlainButtonStyle())
.accessibilityIdentifier(AccessibilityID.Onboarding.titleOptionButton)
.padding([.top], 10)
}

View File

@@ -60,6 +60,7 @@ struct OnboardingWrapup: View {
.background(RoundedRectangle(cornerRadius: 10).fill().foregroundColor(Color.white))
.cornerRadius(10)
})
.accessibilityIdentifier(AccessibilityID.Onboarding.wrapupContinue)
.padding([.top], 65)
}
.multilineTextAlignment(.center)

View File

@@ -26,7 +26,6 @@ extension DataController {
}
func populateMemory() {
#if DEBUG
for idx in 1..<255 {
let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())!
var moodValue = Int.random(in: 2...4)
@@ -43,7 +42,6 @@ extension DataController {
modelContext.insert(entry)
}
save()
#endif
}
/// Creates an entry that is NOT inserted into the context - used for UI placeholders
@@ -79,7 +77,6 @@ extension DataController {
saveAndRunDataListeners()
}
#if DEBUG
func populate2YearsData() {
clearDB()
@@ -100,7 +97,6 @@ extension DataController {
saveAndRunDataListeners()
}
#endif
private static func randomMood() -> Mood {
var moodValue = Int.random(in: 3...4)

View File

@@ -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

View File

@@ -12,6 +12,10 @@ import WidgetKit
@main
struct ReflectApp: App {
private enum AnimationConstants {
static let deepLinkHandlingDelay: TimeInterval = 0.3
}
@Environment(\.scenePhase) private var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@@ -40,6 +44,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
@@ -73,6 +81,7 @@ struct ReflectApp: App {
.alert("Data Storage Unavailable",
isPresented: $showStorageFallbackAlert) {
Button("OK", role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.AppAlert.storageUnavailableOK)
} message: {
Text("Your mood data cannot be saved permanently. Please restart the app. If the problem persists, reinstall the app.")
}
@@ -82,7 +91,7 @@ struct ReflectApp: App {
}
if let url = AppDelegate.pendingDeepLinkURL {
AppDelegate.pendingDeepLinkURL = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.deepLinkHandlingDelay) {
handleDeepLink(url)
}
}
@@ -97,6 +106,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()

View File

@@ -238,6 +238,10 @@ class ReflectTipsManager: ObservableObject {
// MARK: - View Modifier for Easy Integration
struct ReflectTipModifier: ViewModifier {
private enum AnimationConstants {
static let tipPresentationDelay: TimeInterval = 0.5
}
let tip: any ReflectTip
let gradientColors: [Color]
@@ -254,7 +258,7 @@ struct ReflectTipModifier: ViewModifier {
// Delay tip presentation to ensure view hierarchy is fully established
// This prevents "presenting from detached view controller" errors
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.tipPresentationDelay) {
if ReflectTipsManager.shared.shouldShowTip(tip) {
showSheet = true
}

View File

@@ -102,7 +102,9 @@ class BiometricAuthManager: ObservableObject {
}
return success
} catch {
#if DEBUG
print("Authentication failed: \(error.localizedDescription)")
#endif
AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription))
// If biometrics failed, try device passcode as fallback
@@ -126,7 +128,9 @@ class BiometricAuthManager: ObservableObject {
isUnlocked = success
return success
} catch {
#if DEBUG
print("Passcode authentication failed: \(error.localizedDescription)")
#endif
return false
}
}
@@ -146,7 +150,9 @@ class BiometricAuthManager: ObservableObject {
// Only allow enabling if biometrics are available
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
#if DEBUG
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
#endif
return false
}
@@ -164,7 +170,9 @@ class BiometricAuthManager: ObservableObject {
return success
} catch {
#if DEBUG
print("Failed to enable lock: \(error.localizedDescription)")
#endif
return false
}
}

View File

@@ -0,0 +1,123 @@
//
// CognitiveDistortionDetector.swift
// Reflect
//
// Detects common cognitive distortions in a user's automatic-thought response.
// Used by the guided reflection flow to route to a distortion-specific reframe prompt.
//
// This is deterministic keyword matching, not ML chosen for offline support,
// privacy, and predictability. Keywords are sourced from localized strings so
// each language can tune its own detection rules.
//
import Foundation
enum CognitiveDistortionDetector {
/// Detects the most likely cognitive distortion in the given text.
/// Returns `.unknown` if no keywords match the caller should fall back
/// to the generic perspective-check prompt in that case.
///
/// When multiple distortions match, the first one in the priority order below wins.
/// This ordering puts more specific distortions before more general ones.
static func detect(in text: String) -> CognitiveDistortion {
let normalized = text.lowercased()
guard !normalized.trimmingCharacters(in: .whitespaces).isEmpty else {
return .unknown
}
// Priority order: specific general. First hit wins.
let checks: [(CognitiveDistortion, String)] = [
(.catastrophizing, "distortion_catastrophizing_keywords"),
(.mindReading, "distortion_mind_reading_keywords"),
(.personalization, "distortion_personalization_keywords"),
(.labeling, "distortion_labeling_keywords"),
(.shouldStatement, "distortion_should_keywords"),
(.overgeneralization, "distortion_overgeneralization_keywords"),
]
for (distortion, key) in checks {
let keywords = keywordList(forLocalizedKey: key)
if keywords.contains(where: { normalized.contains($0) }) {
return distortion
}
}
return .unknown
}
/// Loads a localized comma-separated keyword list, splits it, and lowercases each entry.
/// Whitespace around entries is trimmed.
private static func keywordList(forLocalizedKey key: String) -> [String] {
let raw = String(localized: String.LocalizationValue(key))
// Guard against an unresolved localization returning the key itself.
guard raw != key else { return [] }
return raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { !$0.isEmpty }
}
}
// MARK: - Distortion-Specific Question Templates
extension CognitiveDistortion {
/// Returns the perspective-check question template (Q3 in the negative path)
/// tailored to this distortion. The template takes the automatic-thought answer
/// as its `%@` placeholder (placeholderRef: 1).
var perspectiveCheckTemplate: QuestionTemplate {
switch self {
case .overgeneralization:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_overgeneralization"),
placeholderRef: 1
)
case .shouldStatement:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_should"),
placeholderRef: 1
)
case .labeling:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_labeling"),
placeholderRef: 1
)
case .personalization:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_personalization"),
placeholderRef: 1
)
case .catastrophizing:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_catastrophizing"),
placeholderRef: 1
)
case .mindReading:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_mind_reading"),
placeholderRef: 1
)
case .unknown:
// Fallback the generic "what would you tell a friend" prompt.
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_templated"),
placeholderRef: 1
)
}
}
/// A short, user-facing name for the distortion used as the step label above
/// the perspective-check question so users learn the CBT vocabulary.
var stepLabel: String {
switch self {
case .overgeneralization: return String(localized: "Overgeneralization")
case .shouldStatement: return String(localized: "Should Statement")
case .labeling: return String(localized: "Labeling")
case .personalization: return String(localized: "Personalization")
case .catastrophizing: return String(localized: "Catastrophizing")
case .mindReading: return String(localized: "Mind Reading")
case .unknown: return String(localized: "Perspective Check")
}
}
}

View File

@@ -87,7 +87,9 @@ class ExportService {
trackDataExported(format: "csv", count: entries.count)
return tempURL
} catch {
#if DEBUG
print("ExportService: Failed to write CSV: \(error)")
#endif
return nil
}
}
@@ -177,7 +179,9 @@ class ExportService {
try data.write(to: tempURL)
return tempURL
} catch {
#if DEBUG
print("ExportService: Failed to write PDF: \(error)")
#endif
return nil
}
}

View File

@@ -4,8 +4,6 @@
//
// Exportable insights views with sample AI-generated insights for screenshots.
//
#if DEBUG
import SwiftUI
// MARK: - Sample Insights Data
@@ -377,4 +375,3 @@ struct ExportableInsightsContainer<Content: View>: View {
.background(backgroundColor)
}
}
#endif

View File

@@ -5,8 +5,6 @@
// Exportable watch views that match the real watchOS layouts.
// These views accept tint/icon configuration as parameters for batch export.
//
#if DEBUG
import SwiftUI
// MARK: - Watch Export Configuration
@@ -362,4 +360,3 @@ struct ExportableComplicationContainer<Content: View>: View {
.clipShape(isCircular ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: 12, style: .continuous)))
}
}
#endif

View File

@@ -5,8 +5,6 @@
// Exportable widget views that match the real WidgetKit widgets pixel-for-pixel.
// These views accept tint/icon configuration as parameters for batch export.
//
#if DEBUG
import SwiftUI
// MARK: - Widget Theme Configuration
@@ -691,4 +689,3 @@ struct ExportableWidgetContainer<Content: View>: View {
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
}
#endif

View File

@@ -0,0 +1,156 @@
//
// 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, options: GenerationOptions(maximumResponseTokens: 300))
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.
Keep summary to 2 sentences. Keep highlight and intention to 1 sentence each.
"""
}
}

View File

@@ -7,6 +7,7 @@
import Foundation
import FoundationModels
import os.log
/// Error types for insight generation
enum InsightGenerationError: Error, LocalizedError {
@@ -29,6 +30,15 @@ enum InsightGenerationError: Error, LocalizedError {
}
}
/// Why Apple Intelligence is unavailable
enum AIUnavailableReason {
case deviceNotEligible
case notEnabled
case modelDownloading
case unknown
case preiOS26
}
/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
@available(iOS 26, *)
@MainActor
@@ -39,6 +49,7 @@ class FoundationModelsInsightService: ObservableObject {
@Published private(set) var isAvailable: Bool = false
@Published private(set) var isGenerating: Bool = false
@Published private(set) var lastError: InsightGenerationError?
@Published private(set) var unavailableReason: AIUnavailableReason = .unknown
// MARK: - Dependencies
@@ -62,15 +73,27 @@ class FoundationModelsInsightService: ObservableObject {
switch model.availability {
case .available:
isAvailable = true
unavailableReason = .unknown
case .unavailable(let reason):
isAvailable = false
unavailableReason = mapUnavailableReason(reason)
lastError = .modelUnavailable(reason: describeUnavailability(reason))
@unknown default:
isAvailable = false
unavailableReason = .unknown
lastError = .modelUnavailable(reason: "Unknown availability status")
}
}
private func mapUnavailableReason(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> AIUnavailableReason {
switch reason {
case .deviceNotEligible: return .deviceNotEligible
case .appleIntelligenceNotEnabled: return .notEnabled
case .modelNotReady: return .modelDownloading
@unknown default: return .unknown
}
}
private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
switch reason {
case .deviceNotEligible:
@@ -84,7 +107,13 @@ class FoundationModelsInsightService: ObservableObject {
}
}
/// Creates a new session for each request to allow concurrent generation
/// Prewarm the language model to reduce first-generation latency
func prewarm() {
let session = LanguageModelSession(instructions: systemInstructions)
session.prewarm()
}
/// Creates a fresh session per request (sessions accumulate transcript, so reuse causes context overflow)
private func createSession() -> LanguageModelSession {
LanguageModelSession(instructions: systemInstructions)
}
@@ -213,8 +242,7 @@ class FoundationModelsInsightService: ObservableObject {
throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available")
}
// Create a new session for this request to allow concurrent generation
let session = createSession()
let activeSession = createSession()
// Filter valid entries
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
@@ -231,9 +259,10 @@ class FoundationModelsInsightService: ObservableObject {
let prompt = buildPrompt(from: summary, count: count)
do {
let response = try await session.respond(
let response = try await activeSession.respond(
to: prompt,
generating: AIInsightsResponse.self
generating: AIInsightsResponse.self,
options: GenerationOptions(maximumResponseTokens: 600)
)
let insights = response.content.insights.map { $0.toInsight() }
@@ -244,9 +273,7 @@ class FoundationModelsInsightService: ObservableObject {
return insights
} catch {
// Log detailed error for debugging
print("AI Insight generation failed for '\(periodName)': \(error)")
print(" Error type: \(type(of: error))")
print(" Localized: \(error.localizedDescription)")
AppLogger.ai.error("AI Insight generation failed for '\(periodName)': \(error)")
lastError = .generationFailed(underlying: error)
throw lastError!
@@ -263,7 +290,7 @@ class FoundationModelsInsightService: ObservableObject {
\(dataSection)
Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points.
Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points. Keep each insight to 1-2 sentences. If theme tags are available, identify what good days and bad days have in common. If weather data is available, note weather-mood correlations. If logging gaps exist, comment on what happens around breaks in tracking.
"""
}

View File

@@ -0,0 +1,152 @@
//
// 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,
options: GenerationOptions(maximumResponseTokens: 200)
)
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")
// Intensity shift if captured, tells the AI how much the reflection helped.
var intensityLine = ""
if let pre = reflection.preIntensity, let post = reflection.postIntensity {
let delta = post - pre
let direction: String
if delta < 0 {
direction = "dropped by \(abs(delta))"
} else if delta > 0 {
direction = "rose by \(delta)"
} else {
direction = "stayed the same"
}
intensityLine = "\nEmotional intensity: \(pre)/10 before → \(post)/10 after (\(direction)).\n"
} else if let pre = reflection.preIntensity {
intensityLine = "\nStarting emotional intensity: \(pre)/10.\n"
}
// Detected cognitive distortion if present, helps the AI speak to the specific
// pattern the user worked through (e.g., "you caught yourself overgeneralizing").
var distortionLine = ""
if let distortion = reflection.detectedDistortion, distortion != .unknown {
distortionLine = "\nDetected cognitive distortion in their automatic thought: \(distortion.rawValue). " +
"Reference this pattern naturally in your observation without being clinical.\n"
}
return """
The user logged their mood as "\(moodName)" and completed a \(technique) reflection:
\(intensityLine)\(distortionLine)
\(qaPairs)
Respond with personalized feedback that references their specific answers\
\(reflection.preIntensity != nil && reflection.postIntensity != nil ? " and acknowledges the shift in how they're feeling" : "").
"""
}
}

View 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, options: GenerationOptions(maximumResponseTokens: 100))
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")
}
}

View File

@@ -71,7 +71,9 @@ class HealthService: ObservableObject {
func requestAuthorization() async -> Bool {
guard isAvailable else {
#if DEBUG
print("HealthService: HealthKit not available on this device")
#endif
return false
}
@@ -82,7 +84,9 @@ class HealthService: ObservableObject {
AnalyticsManager.shared.track(.healthKitAuthorized)
return true
} catch {
#if DEBUG
print("HealthService: Authorization failed: \(error.localizedDescription)")
#endif
AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription))
return false
}

View File

@@ -5,9 +5,9 @@
// Debug utility to export insights view screenshots with sample AI data.
//
#if DEBUG
import SwiftUI
import UIKit
import os.log
/// Exports insights view screenshots for App Store marketing
@MainActor
@@ -28,7 +28,12 @@ class InsightsExporter {
// Clean and create export directory
try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create insights export directory: \(error)")
return nil
}
var totalExported = 0
@@ -95,9 +100,12 @@ class InsightsExporter {
if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() {
try? data.write(to: url)
do {
try data.write(to: url)
} catch {
AppLogger.export.error("Failed to write insights image '\(name)': \(error)")
}
}
}
}
}
#endif

View File

@@ -49,6 +49,23 @@ struct MoodDataSummary {
// Health data for AI analysis (optional)
let healthAverages: HealthService.HealthAverages?
// Tag-mood correlations
let tagFrequencies: [String: Int]
let goodDayTags: [String: Int] // tag counts for entries with mood good/great
let badDayTags: [String: Int] // tag counts for entries with mood bad/horrible
// Weather-mood correlation
let weatherMoodAverages: [String: Double] // condition -> avg mood (1-5 scale)
let tempRangeMoodAverages: [String: Double] // "Cold"/"Mild"/"Warm"/"Hot" -> avg mood
// Absence patterns
let loggingGapCount: Int // number of 2+ day gaps
let preGapMoodAverage: Double // avg mood in 3 days before a gap
let postGapMoodAverage: Double // avg mood in 3 days after returning
// Entry source breakdown
let entrySourceBreakdown: [String: Int] // source name -> count
}
/// Transforms raw MoodEntryModel data into AI-optimized summaries
@@ -83,6 +100,11 @@ class MoodDataSummarizer {
// Format date range
let dateRange = formatDateRange(entries: sortedEntries)
let tagAnalysis = calculateTagAnalysis(entries: validEntries)
let weatherAnalysis = calculateWeatherAnalysis(entries: validEntries)
let absencePatterns = calculateAbsencePatterns(entries: sortedEntries)
let sourceBreakdown = calculateEntrySourceBreakdown(entries: validEntries)
return MoodDataSummary(
periodName: periodName,
totalEntries: validEntries.count,
@@ -107,7 +129,16 @@ class MoodDataSummarizer {
last7DaysMoods: recentContext.moods,
hasAllMoodTypes: moodTypes.hasAll,
missingMoodTypes: moodTypes.missing,
healthAverages: healthAverages
healthAverages: healthAverages,
tagFrequencies: tagAnalysis.frequencies,
goodDayTags: tagAnalysis.goodDayTags,
badDayTags: tagAnalysis.badDayTags,
weatherMoodAverages: weatherAnalysis.conditionAverages,
tempRangeMoodAverages: weatherAnalysis.tempRangeAverages,
loggingGapCount: absencePatterns.gapCount,
preGapMoodAverage: absencePatterns.preGapAverage,
postGapMoodAverage: absencePatterns.postGapAverage,
entrySourceBreakdown: sourceBreakdown
)
}
@@ -346,6 +377,139 @@ class MoodDataSummarizer {
return (hasAll, missing)
}
// MARK: - Tag Analysis
private func calculateTagAnalysis(entries: [MoodEntryModel]) -> (frequencies: [String: Int], goodDayTags: [String: Int], badDayTags: [String: Int]) {
var frequencies: [String: Int] = [:]
var goodDayTags: [String: Int] = [:]
var badDayTags: [String: Int] = [:]
for entry in entries {
let entryTags = entry.tags
guard !entryTags.isEmpty else { continue }
for tag in entryTags {
let normalizedTag = tag.lowercased()
frequencies[normalizedTag, default: 0] += 1
if [.good, .great].contains(entry.mood) {
goodDayTags[normalizedTag, default: 0] += 1
} else if [.bad, .horrible].contains(entry.mood) {
badDayTags[normalizedTag, default: 0] += 1
}
}
}
return (frequencies, goodDayTags, badDayTags)
}
// MARK: - Weather Analysis
private func calculateWeatherAnalysis(entries: [MoodEntryModel]) -> (conditionAverages: [String: Double], tempRangeAverages: [String: Double]) {
var conditionTotals: [String: (total: Int, count: Int)] = [:]
var tempRangeTotals: [String: (total: Int, count: Int)] = [:]
for entry in entries {
guard let json = entry.weatherJSON, let weather = WeatherData.decode(from: json) else { continue }
let moodScore = Int(entry.moodValue) + 1 // 1-5 scale
// Group by weather condition
let condition = weather.condition
let current = conditionTotals[condition, default: (0, 0)]
conditionTotals[condition] = (current.total + moodScore, current.count + 1)
// Group by temperature range (convert Celsius to Fahrenheit)
let tempF = weather.temperature * 9.0 / 5.0 + 32.0
let tempRange: String
if tempF < 50 {
tempRange = "Cold"
} else if tempF <= 70 {
tempRange = "Mild"
} else if tempF <= 85 {
tempRange = "Warm"
} else {
tempRange = "Hot"
}
let currentTemp = tempRangeTotals[tempRange, default: (0, 0)]
tempRangeTotals[tempRange] = (currentTemp.total + moodScore, currentTemp.count + 1)
}
var conditionAverages: [String: Double] = [:]
for (condition, data) in conditionTotals {
conditionAverages[condition] = Double(data.total) / Double(data.count)
}
var tempRangeAverages: [String: Double] = [:]
for (range, data) in tempRangeTotals {
tempRangeAverages[range] = Double(data.total) / Double(data.count)
}
return (conditionAverages, tempRangeAverages)
}
// MARK: - Absence Patterns
private func calculateAbsencePatterns(entries: [MoodEntryModel]) -> (gapCount: Int, preGapAverage: Double, postGapAverage: Double) {
guard entries.count >= 2 else {
return (0, 0, 0)
}
var gapCount = 0
var preGapScores: [Int] = []
var postGapScores: [Int] = []
for i in 1..<entries.count {
let dayDiff = calendar.dateComponents([.day], from: entries[i-1].forDate, to: entries[i].forDate).day ?? 0
guard dayDiff >= 2 else { continue }
gapCount += 1
// Collect up to 3 entries before the gap
let preStart = max(0, i - 3)
for j in preStart..<i {
preGapScores.append(Int(entries[j].moodValue) + 1)
}
// Collect up to 3 entries after the gap
let postEnd = min(entries.count, i + 3)
for j in i..<postEnd {
postGapScores.append(Int(entries[j].moodValue) + 1)
}
}
let preAvg = preGapScores.isEmpty ? 0.0 : Double(preGapScores.reduce(0, +)) / Double(preGapScores.count)
let postAvg = postGapScores.isEmpty ? 0.0 : Double(postGapScores.reduce(0, +)) / Double(postGapScores.count)
return (gapCount, preAvg, postAvg)
}
// MARK: - Entry Source Breakdown
private func calculateEntrySourceBreakdown(entries: [MoodEntryModel]) -> [String: Int] {
var breakdown: [String: Int] = [:]
let sourceNames: [Int: String] = [
0: "App",
1: "Widget",
2: "Watch",
3: "Shortcut",
4: "Auto-fill",
5: "Notification",
6: "Header",
7: "Siri",
8: "Control Center",
9: "Live Activity"
]
for entry in entries {
let name = sourceNames[entry.entryType] ?? "Other"
breakdown[name, default: 0] += 1
}
return breakdown
}
// MARK: - Helpers
private func formatDateRange(entries: [MoodEntryModel]) -> String {
@@ -384,7 +548,16 @@ class MoodDataSummarizer {
last7DaysMoods: [],
hasAllMoodTypes: false,
missingMoodTypes: ["great", "good", "average", "bad", "horrible"],
healthAverages: nil
healthAverages: nil,
tagFrequencies: [:],
goodDayTags: [:],
badDayTags: [:],
weatherMoodAverages: [:],
tempRangeMoodAverages: [:],
loggingGapCount: 0,
preGapMoodAverage: 0,
postGapMoodAverage: 0,
entrySourceBreakdown: [:]
)
}
@@ -469,6 +642,53 @@ class MoodDataSummarizer {
lines.append("Analyze how these health metrics may correlate with mood patterns.")
}
// Tag-mood correlations (only if tags exist)
if !summary.tagFrequencies.isEmpty {
let topTags = summary.tagFrequencies.sorted { $0.value > $1.value }.prefix(8)
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
lines.append("Themes: \(topTags)")
if !summary.goodDayTags.isEmpty {
let goodTags = summary.goodDayTags.sorted { $0.value > $1.value }.prefix(5)
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
lines.append("Good day themes: \(goodTags)")
}
if !summary.badDayTags.isEmpty {
let badTags = summary.badDayTags.sorted { $0.value > $1.value }.prefix(5)
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
lines.append("Bad day themes: \(badTags)")
}
}
// Weather-mood (only if weather data exists)
if !summary.weatherMoodAverages.isEmpty {
let weatherMood = summary.weatherMoodAverages.sorted { $0.value > $1.value }
.map { "\($0.key) avg \(String(format: "%.1f", $0.value))" }.joined(separator: ", ")
lines.append("Weather-mood: \(weatherMood)")
}
if !summary.tempRangeMoodAverages.isEmpty {
let tempMood = ["Cold", "Mild", "Warm", "Hot"].compactMap { range -> String? in
guard let avg = summary.tempRangeMoodAverages[range] else { return nil }
return "\(range) avg \(String(format: "%.1f", avg))"
}.joined(separator: ", ")
if !tempMood.isEmpty {
lines.append("Temp-mood: \(tempMood)")
}
}
// Gaps (only if gaps exist)
if summary.loggingGapCount > 0 {
lines.append("Logging gaps: \(summary.loggingGapCount) breaks of 2+ days. Pre-gap avg: \(String(format: "%.1f", summary.preGapMoodAverage))/5, Post-return avg: \(String(format: "%.1f", summary.postGapMoodAverage))/5")
}
// Sources (only if multiple sources)
if summary.entrySourceBreakdown.count > 1 {
let total = Double(summary.entrySourceBreakdown.values.reduce(0, +))
let sources = summary.entrySourceBreakdown.sorted { $0.value > $1.value }
.map { "\($0.key) \(Int(Double($0.value) / total * 100))%" }.joined(separator: ", ")
lines.append("Entry sources: \(sources)")
}
return lines.joined(separator: "\n")
}
}

View File

@@ -92,7 +92,11 @@ class PhotoManager: ObservableObject {
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
if let thumbnail = createThumbnail(from: image),
let thumbnailData = thumbnail.jpegData(compressionQuality: 0.6) {
try? thumbnailData.write(to: thumbnailURL)
do {
try thumbnailData.write(to: thumbnailURL)
} catch {
AppLogger.photos.error("Failed to save thumbnail: \(error)")
}
}
AnalyticsManager.shared.track(.photoAdded)
@@ -107,13 +111,21 @@ class PhotoManager: ObservableObject {
let filename = "\(id.uuidString).jpg"
let fullURL = photosDir.appendingPathComponent(filename)
guard FileManager.default.fileExists(atPath: fullURL.path),
let data = try? Data(contentsOf: fullURL),
let image = UIImage(data: data) else {
guard FileManager.default.fileExists(atPath: fullURL.path) else {
return nil
}
return image
do {
let data = try Data(contentsOf: fullURL)
guard let image = UIImage(data: data) else {
AppLogger.photos.error("Failed to create UIImage from photo data: \(id)")
return nil
}
return image
} catch {
AppLogger.photos.error("Failed to read photo data for \(id): \(error)")
return nil
}
}
func loadThumbnail(id: UUID) -> UIImage? {
@@ -123,10 +135,15 @@ class PhotoManager: ObservableObject {
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
// Try thumbnail first
if FileManager.default.fileExists(atPath: thumbnailURL.path),
let data = try? Data(contentsOf: thumbnailURL),
let image = UIImage(data: data) {
return image
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
do {
let data = try Data(contentsOf: thumbnailURL)
if let image = UIImage(data: data) {
return image
}
} catch {
AppLogger.photos.error("Failed to read thumbnail data for \(id): \(error)")
}
}
// Fall back to full image if thumbnail doesn't exist
@@ -159,7 +176,11 @@ class PhotoManager: ObservableObject {
// Delete thumbnail
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
try? FileManager.default.removeItem(at: thumbnailURL)
do {
try FileManager.default.removeItem(at: thumbnailURL)
} catch {
AppLogger.photos.error("Failed to delete thumbnail: \(error)")
}
}
if success {
@@ -197,8 +218,13 @@ class PhotoManager: ObservableObject {
var totalPhotoCount: Int {
guard let photosDir = photosDirectory else { return 0 }
let files = try? FileManager.default.contentsOfDirectory(atPath: photosDir.path)
return files?.filter { $0.hasSuffix(".jpg") }.count ?? 0
do {
let files = try FileManager.default.contentsOfDirectory(atPath: photosDir.path)
return files.filter { $0.hasSuffix(".jpg") }.count
} catch {
AppLogger.photos.error("Failed to list photos directory: \(error)")
return 0
}
}
var totalStorageUsed: Int64 {

View File

@@ -5,9 +5,9 @@
// Debug utility to export sharing template screenshots.
//
#if DEBUG
import SwiftUI
import UIKit
import os.log
/// Exports sharing template screenshots for App Store marketing
@MainActor
@@ -21,13 +21,23 @@ class SharingScreenshotExporter {
// Clean and create export directory
try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create sharing export directory: \(error)")
return nil
}
// Create subdirectories
let origDir = exportPath.appendingPathComponent("originals", isDirectory: true)
let varDir = exportPath.appendingPathComponent("variations", isDirectory: true)
try? FileManager.default.createDirectory(at: origDir, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: varDir, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: origDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: varDir, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create sharing subdirectories: \(error)")
return nil
}
var totalExported = 0
let distantPast = Date(timeIntervalSince1970: 0)
@@ -167,10 +177,9 @@ class SharingScreenshotExporter {
try data.write(to: url)
return true
} catch {
print("Failed to save \(name): \(error)")
AppLogger.export.error("Failed to save sharing screenshot '\(name)': \(error)")
}
}
return false
}
}
#endif

View File

@@ -6,9 +6,9 @@
// Uses the exportable watch views from ExportableWatchViews.swift.
//
#if DEBUG
import SwiftUI
import UIKit
import os.log
/// Exports watch view previews to PNG files for App Store screenshots
@MainActor
@@ -76,7 +76,12 @@ class WatchExporter {
// Clean and create export directory
try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create watch export directory: \(error)")
return nil
}
var totalExported = 0
@@ -85,7 +90,12 @@ class WatchExporter {
for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create watch variant directory '\(folderName)': \(error)")
continue
}
let config = WatchExportConfig(
moodTint: tintOption.tint,
@@ -242,9 +252,12 @@ class WatchExporter {
if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() {
try? data.write(to: url)
do {
try data.write(to: url)
} catch {
AppLogger.export.error("Failed to write watch image '\(name)': \(error)")
}
}
}
}
}
#endif

View File

@@ -6,9 +6,9 @@
// Uses the real widget view layouts from ExportableWidgetViews.swift.
//
#if DEBUG
import SwiftUI
import UIKit
import os.log
/// Exports widget previews to PNG files for App Store screenshots
@MainActor
@@ -76,7 +76,12 @@ class WidgetExporter {
// Clean and create export directory
try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create widget export directory: \(error)")
return nil
}
var totalExported = 0
@@ -85,7 +90,12 @@ class WidgetExporter {
for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create variant directory '\(folderName)': \(error)")
continue
}
let config = WidgetExportConfig(
moodTint: tintOption.tint,
@@ -155,7 +165,12 @@ class WidgetExporter {
let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true)
try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create current config export directory: \(error)")
return nil
}
let config = WidgetExportConfig(
moodTint: UserDefaultsStore.moodTintable(),
@@ -177,7 +192,12 @@ class WidgetExporter {
// Clean and create export directory
try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create voting layout export directory: \(error)")
return nil
}
var totalExported = 0
@@ -186,7 +206,12 @@ class WidgetExporter {
for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
do {
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create voting variant directory '\(folderName)': \(error)")
continue
}
let config = WidgetExportConfig(
moodTint: tintOption.tint,
@@ -372,7 +397,11 @@ class WidgetExporter {
if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() {
try? data.write(to: url)
do {
try data.write(to: url)
} catch {
AppLogger.export.error("Failed to write widget image '\(name)': \(error)")
}
}
}
}
@@ -384,9 +413,12 @@ class WidgetExporter {
if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() {
try? data.write(to: url)
do {
try data.write(to: url)
} catch {
AppLogger.export.error("Failed to write live activity image '\(name)': \(error)")
}
}
}
}
}
#endif

View File

@@ -69,7 +69,6 @@ struct AddMoodHeaderView: View {
.background(theme.currentTheme.secondaryBGColor)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier(AccessibilityID.DayView.moodHeader)
}
@ViewBuilder
@@ -125,13 +124,13 @@ struct HorizontalVotingView: View {
}
.buttonStyle(MoodButtonStyle())
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
}
@@ -167,8 +166,6 @@ struct CardVotingView: View {
}
}
.frame(height: 190)
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
private func cardButton(for mood: Mood, width: CGFloat) -> some View {
@@ -190,6 +187,8 @@ struct CardVotingView: View {
)
}
.buttonStyle(CardButtonStyle())
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
@@ -230,13 +229,13 @@ struct StackedVotingView: View {
)
}
.buttonStyle(CardButtonStyle())
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
}
@@ -317,6 +316,8 @@ struct AuraVotingView: View {
}
}
.buttonStyle(AuraButtonStyle(color: color))
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
@@ -355,8 +356,6 @@ struct OrbitVotingView: View {
.onDisappear {
centerPulse = 1.0
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
private func orbitalRing(radius: CGFloat, centerX: CGFloat, centerY: CGFloat) -> some View {
@@ -408,6 +407,8 @@ struct OrbitVotingView: View {
}
.buttonStyle(OrbitButtonStyle(color: color))
.position(x: posX, y: posY)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
@@ -696,7 +697,9 @@ struct NeonEqualizerBar: View {
}
.buttonStyle(NeonBarButtonStyle(isPressed: $isPressed))
.frame(maxWidth: .infinity)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.NeonMoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}

View File

@@ -305,6 +305,12 @@ struct FlipRevealAnimation: View {
struct ShatterReformAnimation: View {
let mood: Mood
private enum AnimationConstants {
static let shatterPhaseDuration: TimeInterval = 0.6
static let checkmarkAppearDelay: TimeInterval = 1.1
static let fadeOutDelay: TimeInterval = 1.8
}
@State private var shardOffsets: [CGSize] = []
@State private var shardRotations: [Double] = []
@State private var shardOpacities: [Double] = []
@@ -354,7 +360,7 @@ struct ShatterReformAnimation: View {
// Phase 2: Converge to center and fade
Task { @MainActor in
try? await Task.sleep(for: .seconds(0.6))
try? await Task.sleep(for: .seconds(AnimationConstants.shatterPhaseDuration))
phase = .reform
withAnimation(.easeInOut(duration: 0.5)) {
for i in 0..<shardCount {
@@ -367,7 +373,7 @@ struct ShatterReformAnimation: View {
// Phase 3: Show checkmark
Task { @MainActor in
try? await Task.sleep(for: .seconds(1.1))
try? await Task.sleep(for: .seconds(AnimationConstants.checkmarkAppearDelay))
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
checkmarkOpacity = 1
}
@@ -375,7 +381,7 @@ struct ShatterReformAnimation: View {
// Phase 4: Fade out
Task { @MainActor in
try? await Task.sleep(for: .seconds(1.8))
try? await Task.sleep(for: .seconds(AnimationConstants.fadeOutDelay))
withAnimation(.easeOut(duration: 0.3)) {
checkmarkOpacity = 0
}

View File

@@ -109,7 +109,8 @@ struct CreateWidgetView: View {
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: 40, maxHeight: .infinity)
.background(.blue)
.accessibilityIdentifier(AccessibilityID.CustomWidget.shuffleButton)
Button(action: {
AnalyticsManager.shared.track(.widgetCreated)
UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: false)
@@ -127,7 +128,8 @@ struct CreateWidgetView: View {
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: 40, maxHeight: .infinity)
.background(.green)
.accessibilityIdentifier(AccessibilityID.CustomWidget.saveButton)
Button(action: {
AnalyticsManager.shared.track(.widgetUsed)
UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: true)
@@ -145,7 +147,8 @@ struct CreateWidgetView: View {
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: 40, maxHeight: .infinity)
.background(.pink)
.accessibilityIdentifier(AccessibilityID.CustomWidget.useButton)
if customWidget.isSaved {
Button(action: {
AnalyticsManager.shared.track(.widgetDeleted)
@@ -163,6 +166,7 @@ struct CreateWidgetView: View {
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: 40, maxHeight: .infinity)
.background(.orange)
.accessibilityIdentifier(AccessibilityID.CustomWidget.deleteButton)
}
}
.frame(minHeight: 40, maxHeight: .infinity)
@@ -178,6 +182,8 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "background"))
}
.labelsHidden()
.accessibilityLabel(String(localized: "create_widget_background_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bg"))
}
.frame(minWidth: 0, maxWidth: .infinity)
@@ -188,6 +194,8 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "inner"))
}
.labelsHidden()
.accessibilityLabel(String(localized: "create_widget_inner_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("inner"))
}
.frame(minWidth: 0, maxWidth: .infinity)
@@ -198,6 +206,8 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "outline"))
}
.labelsHidden()
.accessibilityLabel(String(localized: "create_widget_face_outline_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("stroke"))
}
.frame(minWidth: 0, maxWidth: .infinity)
}
@@ -210,6 +220,8 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "left_eye"))
}
.labelsHidden()
.accessibilityLabel(String(localized: "create_widget_view_left_eye_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("leftEye"))
}
.frame(minWidth: 0, maxWidth: .infinity)
@@ -220,6 +232,8 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "right_eye"))
}
.labelsHidden()
.accessibilityLabel(String(localized: "create_widget_view_right_eye_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("rightEye"))
}
.frame(minWidth: 0, maxWidth: .infinity)
@@ -230,6 +244,8 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "mouth"))
}
.labelsHidden()
.accessibilityLabel(String(localized: "create_widget_view_mouth_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("mouth"))
}
.frame(minWidth: 0, maxWidth: .infinity)
}
@@ -250,16 +266,25 @@ struct CreateWidgetView: View {
.frame(minWidth: 10, idealWidth: 40, maxWidth: 40,
minHeight: 10, idealHeight: 40, maxHeight: 40,
alignment: .center)
.accessibilityIdentifier(AccessibilityID.CustomWidget.backgroundOption(CustomWidgetBackGroundOptions.selectable.firstIndex(of: bg) ?? 0))
.onTapGesture {
update(background: bg)
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Select background \(bg.rawValue)"))
}
mixBG
.accessibilityIdentifier(AccessibilityID.CustomWidget.randomBackgroundButton)
.onTapGesture {
update(background: .random)
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Random background"))
Divider()
ColorPicker("", selection: $customWidget.bgOverlayColor)
.labelsHidden()
.accessibilityLabel(String(localized: "Background overlay color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bgOverlay"))
}
.padding()
.background(
@@ -270,24 +295,30 @@ struct CreateWidgetView: View {
var faceImageOptions: some View {
HStack(alignment: .center) {
Text(String(localized: "create_widget_view_left_eye"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.leftEyeButton)
.onTapGesture(perform: {
showLeftEyeImagePicker.toggle()
})
.accessibilityAddTraits(.isButton)
.foregroundColor(textColor)
.foregroundColor(textColor)
.frame(minWidth: 0, maxWidth: .infinity)
Divider()
Text(String(localized: "create_widget_view_right_eye"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.rightEyeButton)
.onTapGesture(perform: {
showRightEyeImagePicker.toggle()
})
.accessibilityAddTraits(.isButton)
.foregroundColor(textColor)
.frame(minWidth: 0, maxWidth: .infinity)
Divider()
Text(String(localized: "create_widget_view_mouth"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.mouthButton)
.onTapGesture(perform: {
showMuthImagePicker.toggle()
})
.accessibilityAddTraits(.isButton)
.foregroundColor(textColor)
.foregroundColor(textColor)
.frame(minWidth: 0, maxWidth: .infinity)

View File

@@ -492,6 +492,11 @@ struct VotingLayoutPickerCompact: View {
// MARK: - Celebration Animation Picker
struct CelebrationAnimationPickerCompact: View {
private enum AnimationConstants {
static let previewTriggerDelay: TimeInterval = 0.5
static let dismissTransitionDelay: TimeInterval = 0.35
}
@AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0
@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
@@ -538,6 +543,7 @@ struct CelebrationAnimationPickerCompact: View {
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.celebrationAnimationButton(animation.rawValue))
}
}
.padding(.horizontal, 4)
@@ -585,7 +591,7 @@ struct CelebrationAnimationPickerCompact: View {
// Auto-trigger the celebration after a brief pause
Task { @MainActor in
try? await Task.sleep(for: .seconds(0.5))
try? await Task.sleep(for: .seconds(AnimationConstants.previewTriggerDelay))
guard previewAnimation == animation else { return }
if hapticFeedbackEnabled {
HapticFeedbackManager.shared.play(for: animation)
@@ -602,7 +608,7 @@ struct CelebrationAnimationPickerCompact: View {
previewOpacity = 0
}
Task { @MainActor in
try? await Task.sleep(for: .seconds(0.35))
try? await Task.sleep(for: .seconds(AnimationConstants.dismissTransitionDelay))
withAnimation(.easeOut(duration: 0.15)) {
previewAnimation = nil
}
@@ -666,6 +672,7 @@ struct CustomWidgetSection: View {
CustomWidgetView(customWidgetModel: widget)
.frame(width: 60, height: 60)
.cornerRadius(12)
.accessibilityIdentifier(AccessibilityID.Customize.customWidget(UserDefaultsStore.getCustomWidgets().firstIndex(where: { $0.uuid == widget.uuid }) ?? 0))
.onTapGesture {
AnalyticsManager.shared.track(.widgetViewed)
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
@@ -689,6 +696,7 @@ struct CustomWidgetSection: View {
.foregroundColor(.secondary)
}
}
.accessibilityIdentifier(AccessibilityID.Customize.customWidgetAdd)
}
}
@@ -701,6 +709,7 @@ struct CustomWidgetSection: View {
}
.foregroundColor(.accentColor)
}
.accessibilityIdentifier(AccessibilityID.Customize.widgetHowToLink)
}
.sheet(isPresented: $selectedWidget.showSheet) {
if let selectedItem = selectedWidget.selectedItem {
@@ -822,6 +831,7 @@ struct SubscriptionBannerView: View {
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Capsule().fill(Color.green.opacity(0.15)))
.accessibilityIdentifier(AccessibilityID.Customize.manageSubscriptionButton)
}
.padding(16)
}
@@ -866,6 +876,7 @@ struct SubscriptionBannerView: View {
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.unlockPremiumButton)
}
private func openSubscriptionManagement() async {
@@ -873,7 +884,9 @@ struct SubscriptionBannerView: View {
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
#if DEBUG
print("Failed to open subscription management: \(error)")
#endif
}
}
}

View File

@@ -23,6 +23,7 @@ struct CustomWigetView: View {
CustomWidgetView(customWidgetModel: widget)
.frame(width: 50, height: 50)
.cornerRadius(10)
.accessibilityIdentifier(AccessibilityID.Customize.customWidget(UserDefaultsStore.getCustomWidgets().firstIndex(where: { $0.uuid == widget.uuid }) ?? 0))
.onTapGesture {
AnalyticsManager.shared.track(.widgetViewed)
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
@@ -34,6 +35,7 @@ struct CustomWigetView: View {
.overlay(
Image(systemName: "plus")
)
.accessibilityIdentifier(AccessibilityID.Customize.customWidgetAdd)
.onTapGesture {
AnalyticsManager.shared.track(.widgetCreateTapped)
selectedWidget.selectedItem = CustomWidgetModel.randomWidget
@@ -47,6 +49,7 @@ struct CustomWigetView: View {
.cornerRadius(10)
Text("[\(String(localized: "how_to_add_widget"))](https://support.apple.com/guide/iphone/add-widgets-iphb8f1bf206/ios)")
.accessibilityIdentifier(AccessibilityID.Customize.widgetHowToLink)
.accentColor(textColor)
.padding(.bottom)
}

View File

@@ -45,6 +45,7 @@ struct DayFilterPickerView: View {
.cornerRadius(8)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.dayFilterButton(day))
}
}
Text(String(localized: "day_picker_view_text"))

View File

@@ -64,12 +64,15 @@ struct IconPickerView: View {
})
.accessibilityLabel(String(localized: "Default app icon"))
.accessibilityHint(String(localized: "Double tap to select"))
.accessibilityIdentifier(AccessibilityID.Customize.iconButton("default"))
ForEach(iconSets, id: \.self.0){ iconSet in
Button(action: {
UIApplication.shared.setAlternateIconName(iconSet.1) { (error) in
// FIXME: Handle error
UIApplication.shared.setAlternateIconName(iconSet.1) { error in
if let error {
AppLogger.settings.error("Failed to set app icon '\(iconSet.1)': \(error.localizedDescription)")
}
}
AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1))
}, label: {
@@ -80,6 +83,7 @@ struct IconPickerView: View {
})
.accessibilityLabel(String(localized: "App icon style \(iconSet.1.replacingOccurrences(of: "AppIcon", with: "").replacingOccurrences(of: "Image", with: ""))"))
.accessibilityHint(String(localized: "Double tap to select"))
.accessibilityIdentifier(AccessibilityID.Customize.iconButton(iconSet.1))
}
}
.padding()

View File

@@ -41,12 +41,15 @@ struct ImagePackPickerView: View {
.fill(imagePack == images ? theme.currentTheme.bgColor : .clear)
.padding([.top, .bottom], -3)
)
.accessibilityIdentifier(AccessibilityID.Customize.imagePackOption(String(describing: images)))
.onTapGesture {
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
imagePack = images
AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue))
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Select \(String(describing: images)) icon pack"))
if images.rawValue != (MoodImages.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 {
Divider()
}

View File

@@ -38,6 +38,7 @@ struct PersonalityPackPickerView: View {
.fill(personalityPack == aPack ? theme.currentTheme.bgColor : .clear)
.padding(5)
)
.accessibilityIdentifier(AccessibilityID.Customize.personalityPackOption(aPack.title()))
.onTapGesture {
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
@@ -46,6 +47,8 @@ struct PersonalityPackPickerView: View {
LocalNotification.rescheduleNotifiations()
// }
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Select \(aPack.title()) personality pack"))
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
.alert(isPresented: $showOver18Alert) {
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {

View File

@@ -31,9 +31,12 @@ struct ShapePickerView: View {
.resizable()
.frame(width: 20, height: 20, alignment: .trailing)
.foregroundColor(Color(UIColor.systemGray))
.accessibilityIdentifier(AccessibilityID.Customize.shapeRefresh)
.onTapGesture {
shapeRefreshToggleThing.toggle()
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Refresh shapes"))
}
}
@@ -43,12 +46,15 @@ struct ShapePickerView: View {
bgColor: moodTint.color(forMood: Mood.allValues.randomElement()!), textColor: textColor)
.frame(height: 50)
.frame(minWidth: 0, maxWidth: .infinity)
.accessibilityIdentifier(AccessibilityID.Customize.shapeOption(String(describing: ashape)))
.onTapGesture {
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
shape = ashape
AnalyticsManager.shared.track(.moodShapeChanged(shapeId: shape.rawValue))
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Select \(String(describing: ashape)) shape"))
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)

View File

@@ -58,6 +58,7 @@ struct ThemePickerView: View {
.fill(selectedTheme == theme ? selectedTheme.currentTheme.bgColor : .clear)
.padding(-5)
)
.accessibilityIdentifier(AccessibilityID.Customize.themeButton(theme.title))
}
private func selectTheme(_ theme: Theme) {

View File

@@ -59,6 +59,7 @@ struct VotingLayoutPickerView: View {
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.votingLayoutButton(layout.displayName))
}
}
.padding(.horizontal)

View File

@@ -72,7 +72,9 @@ class DayViewViewModel: ObservableObject {
public func update(entry: MoodEntryModel, toMood mood: Mood) {
if !MoodLogger.shared.updateMood(entryDate: entry.forDate, withMood: mood) {
#if DEBUG
print("Failed to update mood entry")
#endif
}
}

View File

@@ -110,10 +110,12 @@ struct EntryListView: View {
if hasNotes {
Image(systemName: "note.text")
.font(.caption2)
.accessibilityHidden(true)
}
if hasReflection {
Image(systemName: "sparkles")
.font(.caption2)
.accessibilityHidden(true)
}
}
.foregroundStyle(.secondary)
@@ -134,7 +136,10 @@ struct EntryListView: View {
if isMissing {
return String(localized: "\(dateString), no mood logged")
} else {
return "\(dateString), \(entry.mood.strValue)"
var description = "\(dateString), \(entry.mood.strValue)"
if hasNotes { description += String(localized: ", has notes") }
if hasReflection { description += String(localized: ", has reflection") }
return description
}
}

View File

@@ -104,6 +104,7 @@ struct ExportView: View {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.Export.cancelButton)
}
}
.sheet(isPresented: $showShareSheet) {
@@ -113,6 +114,7 @@ struct ExportView: View {
}
.alert("Export Failed", isPresented: $showError) {
Button("OK", role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.Export.alertOKButton)
} message: {
Text(errorMessage)
}
@@ -230,6 +232,7 @@ struct ExportView: View {
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Export.formatButton(format.rawValue))
}
}
}
@@ -260,6 +263,7 @@ struct ExportView: View {
.background(Color(.systemBackground))
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Export.rangeButton(range.rawValue))
if range != DateRange.allCases.last {
Divider()
@@ -293,6 +297,7 @@ struct ExportView: View {
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(isExporting || validEntries.isEmpty)
.accessibilityIdentifier(AccessibilityID.Export.exportButton)
.padding(.top, 8)
}

View File

@@ -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() }
)
}
}
}
}
@@ -153,10 +171,28 @@ struct GuidedReflectionView: View {
VStack(alignment: .leading, spacing: 24) {
progressSection
// Pre-intensity rating shown only on the first step, once.
// Captures the baseline emotional intensity so we can measure shift.
if currentStepIndex == 0 {
intensityCard(
title: String(localized: "guided_reflection_pre_intensity_title"),
value: preIntensityBinding
)
}
if let step = currentStep {
stepCard(step)
.id(step.id)
}
// Post-intensity rating shown on the final step, below the question.
// Measures how much the reflection shifted the feeling.
if isLastStep {
intensityCard(
title: String(localized: "guided_reflection_post_intensity_title"),
value: postIntensityBinding
)
}
}
.padding(.horizontal)
.padding(.top, 20)
@@ -166,6 +202,62 @@ struct GuidedReflectionView: View {
.onScrollPhaseChange(handleScrollPhaseChange)
}
// MARK: - Intensity Rating UI
private var preIntensityBinding: Binding<Int> {
Binding(
get: { draft.preIntensity ?? 5 },
set: { draft.preIntensity = $0 }
)
}
private var postIntensityBinding: Binding<Int> {
Binding(
get: { draft.postIntensity ?? 5 },
set: { draft.postIntensity = $0 }
)
}
@ViewBuilder
private func intensityCard(title: String, value: Binding<Int>) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(textColor)
HStack {
Text(String(localized: "guided_reflection_intensity_low"))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text("\(value.wrappedValue) / 10")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(accentColor)
Spacer()
Text(String(localized: "guided_reflection_intensity_high"))
.font(.caption)
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(value.wrappedValue) },
set: { value.wrappedValue = Int($0.rounded()) }
),
in: 0...10,
step: 1
)
.tint(accentColor)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color(.secondarySystemBackground))
)
}
@ToolbarContentBuilder
private var navigationToolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
@@ -236,6 +328,8 @@ struct GuidedReflectionView: View {
.frame(height: 10)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "\(draft.steps.filter(\.hasAnswer).count) of \(draft.steps.count) steps completed"))
}
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
}
@@ -250,7 +344,9 @@ struct GuidedReflectionView: View {
.tracking(1.5)
}
Text(step.question)
// Resolve the template against current answers so Socratic back-references
// (e.g., "Looking at '<your thought>' again...") reflect edits in real time.
Text(draft.resolvedQuestion(for: step))
.font(.title3)
.fontWeight(.medium)
.foregroundColor(textColor)
@@ -259,6 +355,12 @@ struct GuidedReflectionView: View {
editor(for: step)
// Specificity probe gentle nudge if the Q1 (situation) answer is too vague.
// CBT works better on concrete events than generalized feelings.
if step.id == 0 && needsSpecificityProbe(for: step.answer) {
specificityProbe
}
if let chips = step.chips {
ChipSelectionView(
chips: chips,
@@ -277,6 +379,42 @@ struct GuidedReflectionView: View {
)
}
// MARK: - Specificity Probe
/// Vague phrases that should trigger the specificity nudge even if the text is
/// technically long enough. Matched case-insensitively against a trimmed answer.
private static let vaguePhrases: Set<String> = [
"idk", "i don't know", "i dont know",
"nothing", "everything", "nothing really",
"same as always", "same old", "dunno", "no idea"
]
private func needsSpecificityProbe(for answer: String) -> Bool {
let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false } // don't nag before they've started
if trimmed.count < 25 { return true }
let lower = trimmed.lowercased()
return Self.vaguePhrases.contains(where: { lower == $0 || lower.hasPrefix($0 + " ") })
}
private var specificityProbe: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(accentColor)
.font(.footnote)
Text(String(localized: "guided_reflection_specificity_probe"))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(accentColor.opacity(0.08))
)
}
private func editor(for step: GuidedReflectionDraft.Step) -> some View {
VStack(alignment: .leading, spacing: 10) {
AutoSizingReflectionTextEditor(
@@ -401,6 +539,14 @@ struct GuidedReflectionView: View {
private func navigateForward() {
guard let nextStepID = draft.stepID(after: currentStepID) else { return }
focusedStepID = nil
// When leaving Q2 on the negative path, classify the automatic thought and
// swap Q3's template to the tailored reframe prompt. Idempotent and safe
// to run on every forward navigation.
if draft.moodCategory == .negative && currentStepID == 1 {
draft.recomputeDistortion()
}
updateCurrentStep(to: nextStepID)
}
@@ -454,7 +600,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
}
@@ -500,8 +661,11 @@ struct GuidedReflectionView: View {
private struct GuidedReflectionDraft: Equatable {
struct Step: Identifiable, Equatable {
let id: Int
let question: String
let label: String?
/// The template this step renders from. Contains the raw localized text and
/// optional placeholder ref. The user-visible question is computed by calling
/// `GuidedReflectionDraft.resolvedQuestion(for:)` which injects prior answers.
var template: QuestionTemplate
var label: String?
let chips: QuestionChips?
var answer: String
var selectedChips: [String]
@@ -516,7 +680,7 @@ private struct GuidedReflectionDraft: Equatable {
static func == (lhs: Step, rhs: Step) -> Bool {
lhs.id == rhs.id &&
lhs.question == rhs.question &&
lhs.template == rhs.template &&
lhs.label == rhs.label &&
lhs.answer == rhs.answer &&
lhs.selectedChips == rhs.selectedChips
@@ -526,27 +690,86 @@ private struct GuidedReflectionDraft: Equatable {
let moodCategory: MoodCategory
var steps: [Step]
var completedAt: Date?
var preIntensity: Int?
var postIntensity: Int?
var detectedDistortion: CognitiveDistortion?
init(reflection: GuidedReflection) {
moodCategory = reflection.moodCategory
completedAt = reflection.completedAt
preIntensity = reflection.preIntensity
postIntensity = reflection.postIntensity
detectedDistortion = reflection.detectedDistortion
let questions = GuidedReflection.questions(for: reflection.moodCategory)
let templates = GuidedReflection.questionTemplates(for: reflection.moodCategory)
let labels = reflection.moodCategory.stepLabels
steps = questions.enumerated().map { index, question in
steps = templates.enumerated().map { index, template in
// Preserve existing answers if reflection is being resumed.
let existingResponse = reflection.responses.first(where: { $0.id == index })
?? (reflection.responses.indices.contains(index) ? reflection.responses[index] : nil)
return Step(
id: index,
question: question,
template: template,
label: labels.indices.contains(index) ? labels[index] : nil,
chips: QuestionChips.chips(for: reflection.moodCategory, questionIndex: index),
answer: existingResponse?.answer ?? "",
selectedChips: existingResponse?.selectedChips ?? []
)
}
// Re-apply any previously-detected distortion so Q3 restores its tailored template.
if let distortion = detectedDistortion, moodCategory == .negative {
applyDistortion(distortion)
}
}
/// Produces (index, answer) tuples suitable for `QuestionTemplate.resolved(with:)`.
private var answerTuples: [(index: Int, text: String)] {
steps.map { ($0.id, $0.answer) }
}
/// Resolves the user-visible question text for a step, injecting the latest
/// value of any referenced prior answer. Called at render time by the view.
func resolvedQuestion(for step: Step) -> String {
step.template.resolved(with: answerTuples)
}
func resolvedQuestion(forStepID stepID: Int) -> String {
guard let step = step(forStepID: stepID) else { return "" }
return resolvedQuestion(for: step)
}
/// Mutating: detect the cognitive distortion in the current Q2 answer (negative path only)
/// and swap Q3's template to the tailored prompt. Safe to call repeatedly if Q2 is empty
/// or detection yields `.unknown` this resets to the fallback template.
mutating func recomputeDistortion() {
guard moodCategory == .negative,
let q2 = steps.first(where: { $0.id == 1 }),
!q2.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
detectedDistortion = nil
applyDistortion(.unknown) // reset Q3 label to generic
return
}
let distortion = CognitiveDistortionDetector.detect(in: q2.answer)
detectedDistortion = distortion == .unknown ? nil : distortion
applyDistortion(distortion)
}
/// Overwrites Q3's template + label based on the detected distortion.
private mutating func applyDistortion(_ distortion: CognitiveDistortion) {
guard let q3Index = steps.firstIndex(where: { $0.id == 2 }) else { return }
steps[q3Index].template = distortion.perspectiveCheckTemplate
if distortion != .unknown {
steps[q3Index].label = distortion.stepLabel
} else {
// Reset to the default "Perspective Check" label from MoodCategory.stepLabels.
let defaults = moodCategory.stepLabels
steps[q3Index].label = defaults.indices.contains(2) ? defaults[2] : nil
}
}
var firstUnansweredStepID: Int? {
@@ -595,14 +818,19 @@ private struct GuidedReflectionDraft: Equatable {
GuidedReflection(
moodCategory: moodCategory,
responses: steps.map { step in
// Persist the user-visible resolved question text not the raw template
// so downstream consumers (AI feedback, history view) see what the user saw.
GuidedReflection.Response(
id: step.id,
question: step.question,
question: resolvedQuestion(for: step),
answer: step.answer,
selectedChips: step.selectedChips
)
},
completedAt: completedAt
completedAt: completedAt,
preIntensity: preIntensity,
postIntensity: postIntensity,
detectedDistortion: detectedDistortion
)
}
}

View File

@@ -29,6 +29,7 @@ struct ImagePickerGridView: View {
.scaledToFit()
.frame(width: 40, height: 40)
.foregroundColor(textColor)
.accessibilityIdentifier(AccessibilityID.CustomWidget.imageOption(item.rawValue))
.onTapGesture {
pickedImageClosure(item)
presentationMode.wrappedValue.dismiss()

View File

@@ -13,10 +13,15 @@ enum InsightsTab: String, CaseIterable {
}
struct InsightsView: View {
private enum AnimationConstants {
static let refreshDelay: UInt64 = 500_000_000 // 0.5 seconds in nanoseconds
}
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var scenePhase
private var textColor: Color { theme.currentTheme.labelColor }
@@ -24,6 +29,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) {
@@ -40,6 +47,7 @@ struct InsightsView: View {
HStack(spacing: 4) {
Image(systemName: "sparkles")
.font(.caption.weight(.medium))
.accessibilityHidden(true)
Text("AI")
.font(.caption.weight(.semibold))
}
@@ -82,6 +90,10 @@ struct InsightsView: View {
if iapManager.shouldShowPaywall {
paywallOverlay
}
if selectedTab == .insights && isGeneratingInsights && !iapManager.shouldShowPaywall {
generatingOverlay
}
}
}
.sheet(isPresented: $showSubscriptionStore) {
@@ -94,15 +106,52 @@ struct InsightsView: View {
.onAppear {
AnalyticsManager.shared.trackScreen(.insights)
viewModel.generateInsights()
loadWeeklyDigest()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
viewModel.recheckAvailability()
}
}
.padding(.top)
}
// MARK: - Insights Content
private func loadWeeklyDigest() {
guard #available(iOS 26, *), !iapManager.shouldShowPaywall else { return }
// Try cached digest first
if let digest = FoundationModelsDigestService.shared.loadLatestDigest(),
digest.isFromCurrentWeek {
weeklyDigest = digest
return
}
// No digest for this week generate one on-demand
Task {
do {
let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest()
weeklyDigest = digest
} catch {
// Not enough data or AI unavailable just don't show the card
}
}
}
private var insightsContent: some View {
ScrollView {
VStack(spacing: 20) {
// AI enablement guidance when not available
if !viewModel.isAIAvailable && !iapManager.shouldShowPaywall {
aiEnablementCard
}
// Weekly Digest Card
if let digest = weeklyDigest {
WeeklyDigestCardView(digest: digest)
}
// This Month Section
InsightsSectionView(
title: "This Month",
@@ -145,14 +194,145 @@ struct InsightsView: View {
.padding(.vertical)
.padding(.bottom, 100)
}
.opacity(isGeneratingInsights && !iapManager.shouldShowPaywall ? 0.2 : 1.0)
.animation(.easeInOut(duration: 0.3), value: isGeneratingInsights)
.refreshable {
viewModel.refreshInsights()
// Small delay to show refresh animation
try? await Task.sleep(nanoseconds: 500_000_000)
try? await Task.sleep(nanoseconds: AnimationConstants.refreshDelay)
}
.disabled(iapManager.shouldShowPaywall)
}
// MARK: - AI Enablement Card
private var aiEnablementCard: some View {
VStack(spacing: 16) {
Image(systemName: aiEnablementIcon)
.font(.system(size: 36))
.foregroundStyle(.secondary)
Text(aiEnablementTitle)
.font(.headline)
.foregroundColor(textColor)
Text(aiEnablementDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if viewModel.aiUnavailableReason == .notEnabled {
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label(String(localized: "Open Settings"), systemImage: "gear")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
if viewModel.aiUnavailableReason == .modelDownloading {
Button {
viewModel.recheckAvailability()
} label: {
Label(String(localized: "Try Again"), systemImage: "arrow.clockwise")
.font(.subheadline.weight(.medium))
}
.buttonStyle(.bordered)
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color(.secondarySystemBackground))
)
.padding(.horizontal)
}
private var aiEnablementIcon: String {
switch viewModel.aiUnavailableReason {
case .deviceNotEligible: return "iphone.slash"
case .notEnabled: return "gearshape.fill"
case .modelDownloading: return "arrow.down.circle"
case .preiOS26: return "arrow.up.circle"
case .unknown: return "brain.head.profile"
}
}
private var aiEnablementTitle: String {
switch viewModel.aiUnavailableReason {
case .deviceNotEligible: return String(localized: "Device Not Supported")
case .notEnabled: return String(localized: "Enable Apple Intelligence")
case .modelDownloading: return String(localized: "AI Model Downloading")
case .preiOS26: return String(localized: "Update Required")
case .unknown: return String(localized: "AI Unavailable")
}
}
private var aiEnablementDescription: String {
switch viewModel.aiUnavailableReason {
case .deviceNotEligible:
return String(localized: "AI insights require iPhone 15 Pro or later with Apple Intelligence.")
case .notEnabled:
return String(localized: "Turn on Apple Intelligence to unlock personalized mood insights.\n\nSettings → Apple Intelligence & Siri → Apple Intelligence")
case .modelDownloading:
return String(localized: "The AI model is still downloading. This may take a few minutes.")
case .preiOS26:
return String(localized: "AI insights require iOS 26 or later with Apple Intelligence.")
case .unknown:
return String(localized: "Apple Intelligence is required for personalized insights.")
}
}
// MARK: - Generating State
private var isGeneratingInsights: Bool {
let states = [viewModel.monthLoadingState, viewModel.yearLoadingState, viewModel.allTimeLoadingState]
return states.contains(where: { $0 == .loading })
}
private var generatingOverlay: some View {
VStack(spacing: 20) {
Spacer()
VStack(spacing: 16) {
Image(systemName: "sparkles")
.font(.system(size: 36))
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
.symbolEffect(.pulse, options: .repeating)
Text(String(localized: "Generating Insights"))
.font(.headline)
.foregroundColor(textColor)
Text(String(localized: "Apple Intelligence is analyzing your mood data..."))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(32)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(.regularMaterial)
)
.padding(.horizontal, 40)
Spacer()
}
.transition(.opacity)
}
// MARK: - Paywall Overlay
private var paywallOverlay: some View {
@@ -173,6 +353,7 @@ struct InsightsView: View {
Image(systemName: "sparkles")
.font(.largeTitle)
.accessibilityHidden(true)
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
@@ -202,6 +383,7 @@ struct InsightsView: View {
} label: {
HStack {
Image(systemName: "sparkles")
.accessibilityHidden(true)
Text("Get Personal Insights")
}
.font(.headline.weight(.bold))
@@ -277,6 +459,7 @@ struct InsightsSectionView: View {
.padding(.vertical, 14)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Insights.expandCollapseButton)
.accessibilityAddTraits(.isHeader)
// Insights List (collapsible)

View File

@@ -40,6 +40,7 @@ class InsightsViewModel: ObservableObject {
@Published var allTimeLoadingState: InsightLoadingState = .idle
@Published var isAIAvailable: Bool = false
@Published var aiUnavailableReason: AIUnavailableReason = .preiOS26
// MARK: - Dependencies
@@ -57,9 +58,12 @@ class InsightsViewModel: ObservableObject {
let service = FoundationModelsInsightService()
insightService = service
isAIAvailable = service.isAvailable
aiUnavailableReason = service.unavailableReason
service.prewarm()
} else {
insightService = nil
isAIAvailable = false
aiUnavailableReason = .preiOS26
}
dataListenerToken = DataController.shared.addNewDataListener { [weak self] in
@@ -118,12 +122,29 @@ class InsightsViewModel: ObservableObject {
let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
let allTimeEntries = DataController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
// Pre-fetch health data once (instead of 3x per period)
var sharedHealthAverages: HealthService.HealthAverages?
if healthService.isEnabled && healthService.isAuthorized {
let allValidEntries = allTimeEntries.filter { ![.missing, .placeholder].contains($0.mood) }
if !allValidEntries.isEmpty {
let healthData = await healthService.fetchHealthData(for: allValidEntries)
sharedHealthAverages = healthService.computeHealthAverages(entries: allValidEntries, healthData: healthData)
}
}
// Set all states to loading upfront so the overlay dismisses
// as soon as all tasks complete (not one-by-one)
monthLoadingState = .loading
yearLoadingState = .loading
allTimeLoadingState = .loading
// Generate insights concurrently for all three periods
await withTaskGroup(of: Void.self) { group in
group.addTask { @MainActor in
await self.generatePeriodInsights(
entries: monthEntries,
periodName: "this month",
healthAverages: sharedHealthAverages,
updateState: { self.monthLoadingState = $0 },
updateInsights: { self.monthInsights = $0 }
)
@@ -133,6 +154,7 @@ class InsightsViewModel: ObservableObject {
await self.generatePeriodInsights(
entries: yearEntries,
periodName: "this year",
healthAverages: sharedHealthAverages,
updateState: { self.yearLoadingState = $0 },
updateInsights: { self.yearInsights = $0 }
)
@@ -142,6 +164,7 @@ class InsightsViewModel: ObservableObject {
await self.generatePeriodInsights(
entries: allTimeEntries,
periodName: "all time",
healthAverages: sharedHealthAverages,
updateState: { self.allTimeLoadingState = $0 },
updateInsights: { self.allTimeInsights = $0 }
)
@@ -152,6 +175,7 @@ class InsightsViewModel: ObservableObject {
private func generatePeriodInsights(
entries: [MoodEntryModel],
periodName: String,
healthAverages: HealthService.HealthAverages?,
updateState: @escaping (InsightLoadingState) -> Void,
updateInsights: @escaping ([Insight]) -> Void
) async {
@@ -170,27 +194,16 @@ class InsightsViewModel: ObservableObject {
return
}
// Check if AI is available
// Check if AI is available show reason-specific guidance
guard isAIAvailable else {
updateInsights([Insight(
icon: "brain.head.profile",
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
let (icon, title, description) = unavailableMessage()
updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)])
updateState(.error("AI not available"))
return
}
updateState(.loading)
// Fetch health data if enabled - pass raw averages to AI for correlation analysis
var healthAverages: HealthService.HealthAverages?
if healthService.isEnabled && healthService.isAuthorized {
let healthData = await healthService.fetchHealthData(for: validEntries)
healthAverages = healthService.computeHealthAverages(entries: validEntries, healthData: healthData)
}
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
do {
let insights = try await service.generateInsights(
@@ -212,13 +225,47 @@ class InsightsViewModel: ObservableObject {
updateState(.error(error.localizedDescription))
}
} else {
updateInsights([Insight(
icon: "brain.head.profile",
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
let (icon, title, description) = unavailableMessage()
updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)])
updateState(.error("AI not available"))
}
}
// MARK: - Unavailable Messages
private func unavailableMessage() -> (icon: String, title: String, description: String) {
switch aiUnavailableReason {
case .deviceNotEligible:
return ("iphone.slash", "Device Not Supported",
String(localized: "AI insights require iPhone 15 Pro or later with Apple Intelligence."))
case .notEnabled:
return ("gearshape.fill", "Apple Intelligence Disabled",
String(localized: "Turn on Apple Intelligence in Settings → Apple Intelligence & Siri to unlock AI insights."))
case .modelDownloading:
return ("arrow.down.circle", "AI Model Downloading",
String(localized: "The AI model is still downloading. Please wait a few minutes and try again."))
case .preiOS26:
return ("arrow.up.circle", "Update Required",
String(localized: "AI insights require iOS 26 or later with Apple Intelligence."))
case .unknown:
return ("brain.head.profile", "AI Unavailable",
String(localized: "Apple Intelligence is required for personalized insights."))
}
}
/// Re-check AI availability (e.g., after returning from Settings)
func recheckAvailability() {
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
service.checkAvailability()
let wasAvailable = isAIAvailable
isAIAvailable = service.isAvailable
aiUnavailableReason = service.unavailableReason
// If just became available, generate insights
if !wasAvailable && isAIAvailable {
service.prewarm()
generateInsights()
}
}
}
}

View File

@@ -146,6 +146,7 @@ struct ReportDateRangePicker: View {
.background(Color.accentColor.opacity(0.15))
.clipShape(Circle())
}
.accessibilityIdentifier(AccessibilityID.Reports.previousMonthButton)
.accessibilityLabel("Previous month")
Spacer()
@@ -172,6 +173,7 @@ struct ReportDateRangePicker: View {
.background(Color.accentColor.opacity(0.15))
.clipShape(Circle())
}
.accessibilityIdentifier(AccessibilityID.Reports.nextMonthButton)
.accessibilityLabel("Next month")
.disabled(isDisplayingCurrentMonth)
}
@@ -341,6 +343,7 @@ private struct ReportDayCell: View {
}
.buttonStyle(.plain)
.disabled(isFuture)
.accessibilityIdentifier(AccessibilityID.Reports.dayCell(dateString: dayNumber))
.frame(height: 40)
}
}

View File

@@ -95,7 +95,9 @@ struct ReportsView: View {
viewModel.exportDataPDF()
}
}
.accessibilityIdentifier(AccessibilityID.Reports.privacyShareButton)
Button(String(localized: "Cancel"), role: .cancel) {}
.accessibilityIdentifier(AccessibilityID.Reports.privacyCancelButton)
} message: {
Text("This report contains your personal mood data and journal notes. Only share it with people you trust.")
}

View File

@@ -79,6 +79,10 @@ class ReportsViewModel: ObservableObject {
let service = FoundationModelsInsightService()
insightService = service
isAIAvailable = service.isAvailable
service.prewarm()
// Also prewarm the clinical session used for reports
let clinicalSession = LanguageModelSession(instructions: clinicalSystemInstructions)
clinicalSession.prewarm()
} else {
insightService = nil
isAIAvailable = false
@@ -205,7 +209,7 @@ class ReportsViewModel: ObservableObject {
"""
do {
let response = try await session.respond(to: prompt, generating: AIQuickSummaryResponse.self)
let response = try await session.respond(to: prompt, generating: AIQuickSummaryResponse.self, options: GenerationOptions(maximumResponseTokens: 400))
guard !Task.isCancelled else { throw CancellationError() }
@@ -251,10 +255,11 @@ class ReportsViewModel: ObservableObject {
let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count
var completedSections = 0
// Generate weekly AI summaries batched at 4 concurrent
// Generate AI summaries fresh session per call, batched at 4 concurrent
if #available(iOS 26, *) {
let batchSize = 4
let batchSize = 2
// Weekly summaries batched at 4 concurrent
for batchStart in stride(from: 0, to: weeks.count, by: batchSize) {
guard !Task.isCancelled else { throw CancellationError() }
@@ -279,46 +284,60 @@ class ReportsViewModel: ObservableObject {
}
}
// Generate monthly AI summaries concurrent
// Monthly summaries batched at 4 concurrent
guard !Task.isCancelled else { throw CancellationError() }
progressMessage = String(localized: "Generating monthly summaries...")
await withTaskGroup(of: (Int, String?).self) { group in
for (index, monthSummary) in monthlySummaries.enumerated() {
group.addTask { @MainActor in
let summary = await self.generateMonthlySummary(month: monthSummary, allEntries: reportEntries)
return (index, summary)
}
}
for batchStart in stride(from: 0, to: monthlySummaries.count, by: batchSize) {
guard !Task.isCancelled else { throw CancellationError() }
for await (index, summary) in group {
monthlySummaries[index].aiSummary = summary
completedSections += 1
progressValue = Double(completedSections) / Double(totalSections)
}
}
// Generate yearly AI summaries concurrent
guard !Task.isCancelled else { throw CancellationError() }
if !yearlySummaries.isEmpty {
progressMessage = String(localized: "Generating yearly summaries...")
let batchEnd = min(batchStart + batchSize, monthlySummaries.count)
let batchIndices = batchStart..<batchEnd
await withTaskGroup(of: (Int, String?).self) { group in
for (index, yearSummary) in yearlySummaries.enumerated() {
for index in batchIndices {
group.addTask { @MainActor in
let summary = await self.generateYearlySummary(year: yearSummary, allEntries: reportEntries)
let summary = await self.generateMonthlySummary(month: monthlySummaries[index], allEntries: reportEntries)
return (index, summary)
}
}
for await (index, summary) in group {
yearlySummaries[index].aiSummary = summary
monthlySummaries[index].aiSummary = summary
completedSections += 1
progressValue = Double(completedSections) / Double(totalSections)
}
}
}
// Yearly summaries batched at 4 concurrent
guard !Task.isCancelled else { throw CancellationError() }
if !yearlySummaries.isEmpty {
progressMessage = String(localized: "Generating yearly summaries...")
for batchStart in stride(from: 0, to: yearlySummaries.count, by: batchSize) {
guard !Task.isCancelled else { throw CancellationError() }
let batchEnd = min(batchStart + batchSize, yearlySummaries.count)
let batchIndices = batchStart..<batchEnd
await withTaskGroup(of: (Int, String?).self) { group in
for index in batchIndices {
group.addTask { @MainActor in
let summary = await self.generateYearlySummary(year: yearlySummaries[index], allEntries: reportEntries)
return (index, summary)
}
}
for await (index, summary) in group {
yearlySummaries[index].aiSummary = summary
completedSections += 1
progressValue = Double(completedSections) / Double(totalSections)
}
}
}
}
}
return MoodReport(
@@ -337,7 +356,6 @@ class ReportsViewModel: ObservableObject {
@available(iOS 26, *)
private func generateWeeklySummary(week: ReportWeek) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
let mood = entry.mood.widgetDisplayName
@@ -358,7 +376,7 @@ class ReportsViewModel: ObservableObject {
"""
do {
let response = try await session.respond(to: prompt, generating: AIWeeklySummary.self)
let response = try await session.respond(to: prompt, generating: AIWeeklySummary.self, options: GenerationOptions(maximumResponseTokens: 150))
return response.content.summary
} catch {
return "Summary unavailable"
@@ -368,7 +386,6 @@ class ReportsViewModel: ObservableObject {
@available(iOS 26, *)
private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let monthEntries = allEntries.filter {
calendar.component(.month, from: $0.date) == month.month &&
calendar.component(.year, from: $0.date) == month.year
@@ -387,7 +404,7 @@ class ReportsViewModel: ObservableObject {
"""
do {
let response = try await session.respond(to: prompt, generating: AIMonthSummary.self)
let response = try await session.respond(to: prompt, generating: AIMonthSummary.self, options: GenerationOptions(maximumResponseTokens: 150))
return response.content.summary
} catch {
return "Summary unavailable"
@@ -397,7 +414,6 @@ class ReportsViewModel: ObservableObject {
@available(iOS 26, *)
private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year }
let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) }
@@ -420,7 +436,7 @@ class ReportsViewModel: ObservableObject {
"""
do {
let response = try await session.respond(to: prompt, generating: AIYearSummary.self)
let response = try await session.respond(to: prompt, generating: AIYearSummary.self, options: GenerationOptions(maximumResponseTokens: 150))
return response.content.summary
} catch {
return "Summary unavailable"

View File

@@ -0,0 +1,138 @@
//
// WeeklyDigestCardView.swift
// Reflect
//
// Displays the AI-generated weekly emotional digest card in the Insights tab.
//
import SwiftUI
struct WeeklyDigestCardView: View {
let digest: WeeklyDigest
@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 isExpanded = true
@State private var appeared = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header always visible, tappable to toggle
Button {
withAnimation(.easeInOut(duration: 0.25)) {
isExpanded.toggle()
}
} label: {
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)
.multilineTextAlignment(.leading)
}
Spacer()
Image(systemName: "chevron.up")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 0 : 180))
}
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.WeeklyDigest.dismissButton)
// Expandable content
if isExpanded {
VStack(alignment: .leading, spacing: 16) {
// 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(textColor)
.fixedSize(horizontal: false, vertical: true)
}
// Date range
Text(dateRangeString)
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.top, 16)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.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))"
}
}

View File

@@ -1465,6 +1465,12 @@ struct GlassButton: View {
// MARK: - Main Lock Screen View
struct LockScreenView: View {
private enum AnimationConstants {
static let contentAppearDuration: TimeInterval = 0.8
static let contentAppearDelay: TimeInterval = 0.2
static let authenticationDelay: Int = 800 // milliseconds
}
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var authManager: BiometricAuthManager
@State private var showError = false
@@ -1691,6 +1697,7 @@ struct LockScreenView: View {
.disabled(authManager.isAuthenticating)
.padding(.top, 16)
.opacity(showContent ? 1 : 0)
.accessibilityIdentifier(AccessibilityID.LockScreen.passcodeUnlockButton)
.accessibilityLabel("Use device passcode")
.accessibilityHint("Double tap to authenticate with your device passcode")
}
@@ -1713,13 +1720,13 @@ struct LockScreenView: View {
Text("Unable to verify your identity. Please try again.")
}
.onAppear {
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
withAnimation(.easeOut(duration: AnimationConstants.contentAppearDuration).delay(AnimationConstants.contentAppearDelay)) {
showContent = true
}
if !authManager.isUnlocked && !authManager.isAuthenticating {
Task {
try? await Task.sleep(for: .milliseconds(800))
try? await Task.sleep(for: .milliseconds(AnimationConstants.authenticationDelay))
await authManager.authenticate()
}
}

View File

@@ -58,11 +58,13 @@ struct MonthDetailView: View {
.onTapGesture {
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
let _image = self.image
self.shareImage.showSheet = true
self.shareImage.selectedShareImage = _image
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Share month"))
}
.background(
theme.currentTheme.secondaryBGColor
@@ -117,6 +119,7 @@ struct MonthDetailView: View {
selectedEntry = nil
showUpdateEntryAlert = false
})
.accessibilityIdentifier(AccessibilityID.MonthDetail.cancelButton)
}
}
@@ -153,12 +156,14 @@ struct MonthDetailView: View {
LazyVGrid(columns: columns, spacing: 25) {
ForEach(entries, id: \.self) { entry in
listViewEntry(forEntry: entry)
.accessibilityIdentifier(AccessibilityID.MonthDetail.entryCell(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)))
.onTapGesture(perform: {
if entry.canEdit {
selectedEntry = entry
showUpdateEntryAlert = true
}
})
.accessibilityAddTraits(.isButton)
.frame(minWidth: 0, maxWidth: .infinity)
}
}

View File

@@ -378,6 +378,7 @@ struct MonthView: View {
.preferredColorScheme(theme.preferredColorScheme)
#if DEBUG
// Triple-tap to toggle demo mode for video recording
.accessibilityIdentifier(AccessibilityID.MonthView.debugDemoToggle)
.onTapGesture(count: 3) {
if demoManager.isDemoMode {
demoManager.stopDemoMode()
@@ -591,6 +592,7 @@ struct MonthCard: View, Equatable {
}
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.MonthView.statsToggleButton)
.accessibilityLabel("\(Random.monthName(fromMonthInt: month)) \(String(year)), \(showStats ? "expanded" : "collapsed")")
.accessibilityHint("Double tap to toggle statistics")
@@ -661,6 +663,7 @@ struct MonthCard: View, Equatable {
.fill(theme.currentTheme.secondaryBGColor)
)
.contentShape(Rectangle())
.accessibilityIdentifier(AccessibilityID.MonthView.dayCell(dateString: "\(month)_\(year)"))
.onTapGesture {
onTap()
}
@@ -867,6 +870,7 @@ extension MonthView {
}
.padding(.top, 60)
.padding(.trailing)
.accessibilityIdentifier(AccessibilityID.MonthView.settingsButton)
Spacer()
}
}

View File

@@ -10,6 +10,10 @@ import PhotosUI
struct NoteEditorView: View {
private enum AnimationConstants {
static let keyboardAppearDelay: TimeInterval = 0.5
}
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@@ -57,18 +61,18 @@ struct NoteEditorView: View {
}
.padding()
}
.navigationTitle("Journal Note")
.navigationTitle(String(localized: "Journal Note"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
Button(String(localized: "Cancel")) {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
Button(String(localized: "Save")) {
saveNote()
}
.disabled(isSaving || noteText.count > maxCharacters)
@@ -78,14 +82,14 @@ struct NoteEditorView: View {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
Button(String(localized: "Done")) {
isTextFieldFocused = false
}
.accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton)
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.keyboardAppearDelay) {
isTextFieldFocused = true
}
}
@@ -129,6 +133,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 +196,11 @@ struct EntryDetailView: View {
// Mood section
moodSection
// Tags section
if entry.hasTags {
tagsSection
}
// Guided reflection section
if currentMood != .missing && currentMood != .placeholder {
reflectionSection
@@ -205,12 +220,12 @@ struct EntryDetailView: View {
.padding()
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Entry Details")
.navigationTitle(String(localized: "Entry Details"))
.navigationBarTitleDisplayMode(.inline)
.accessibilityIdentifier(AccessibilityID.EntryDetail.sheet)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
Button(String(localized: "Done")) {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton)
@@ -222,16 +237,16 @@ struct EntryDetailView: View {
.sheet(isPresented: $showReflectionFlow) {
GuidedReflectionView(entry: entry)
}
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
.alert(String(localized: "Delete Entry"), isPresented: $showDeleteConfirmation) {
Button(String(localized: "Delete"), role: .destructive) {
onDelete()
dismiss()
}
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton)
Button("Cancel", role: .cancel) { }
Button(String(localized: "Cancel"), role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton)
} message: {
Text("Are you sure you want to delete this mood entry? This cannot be undone.")
Text(String(localized: "Are you sure you want to delete this mood entry? This cannot be undone."))
}
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
.onChange(of: selectedPhotoItem) { _, newItem in
@@ -389,6 +404,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 {

View File

@@ -76,6 +76,7 @@ struct PhotoPickerView: View {
)
}
.disabled(isProcessing)
.accessibilityIdentifier(AccessibilityID.PhotoPicker.photosPicker)
// Camera
Button {
@@ -111,6 +112,7 @@ struct PhotoPickerView: View {
)
}
.disabled(isProcessing)
.accessibilityIdentifier(AccessibilityID.PhotoPicker.cameraButton)
}
.padding(.horizontal)
@@ -130,6 +132,7 @@ struct PhotoPickerView: View {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.PhotoPicker.cancelButton)
}
}
.onChange(of: selectedItem) { _, newItem in
@@ -157,7 +160,9 @@ struct PhotoPickerView: View {
handleSelectedImage(image)
}
} catch {
#if DEBUG
print("PhotoPickerView: Failed to load image: \(error)")
#endif
}
}
@@ -278,6 +283,7 @@ struct PhotoGalleryView: View {
}
}
}
.accessibilityIdentifier(AccessibilityID.PhotoPicker.photoImage)
} else {
VStack(spacing: 16) {
Image(systemName: "photo.badge.exclamationmark")
@@ -301,6 +307,7 @@ struct PhotoGalleryView: View {
.font(.title2)
.foregroundStyle(.white.opacity(0.7))
}
.accessibilityIdentifier(AccessibilityID.PhotoPicker.closeButton)
}
ToolbarItem(placement: .primaryAction) {
@@ -310,17 +317,20 @@ struct PhotoGalleryView: View {
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.accessibilityIdentifier(AccessibilityID.PhotoPicker.shareButton)
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
.accessibilityIdentifier(AccessibilityID.PhotoPicker.deleteButton)
} label: {
Image(systemName: "ellipsis.circle.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.7))
}
.accessibilityIdentifier(AccessibilityID.PhotoPicker.menuButton)
}
}
.confirmationDialog("Delete Photo", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
@@ -328,7 +338,9 @@ struct PhotoGalleryView: View {
onDelete()
dismiss()
}
.accessibilityIdentifier(AccessibilityID.PhotoPicker.deleteConfirmButton)
Button("Cancel", role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.PhotoPicker.deleteCancelButton)
} message: {
Text("Are you sure you want to delete this photo?")
}

View File

@@ -175,6 +175,7 @@ struct PurchaseButtonView: View {
.background(Color.pink)
.cornerRadius(10)
}
.accessibilityIdentifier(AccessibilityID.Purchase.subscribeButton)
// Restore purchases
Button {

View File

@@ -46,6 +46,7 @@ struct ReflectSubscriptionStoreView: View {
}
.padding(16)
.accessibilityLabel("Close")
.accessibilityIdentifier(AccessibilityID.SubscriptionStore.closeButton)
}
.onAppear {
AppLogger.iap.info("SubscriptionStoreView appeared — source: \(source), productIDs: \(IAPManager.productIdentifiers.sorted().joined(separator: ", ")), groupID: \(IAPManager.subscriptionGroupID)")

View 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())
}
}

View File

@@ -22,9 +22,12 @@ struct SampleEntryView: View {
.resizable()
.frame(width: 20, height: 20, alignment: .trailing)
.foregroundColor(Color(UIColor.systemGray))
.accessibilityIdentifier(AccessibilityID.SampleEntry.refreshButton)
.onTapGesture {
sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: sampleListEntry.mood.next)
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Refresh sample entry"))
}
Spacer()
}.padding()

View File

@@ -54,6 +54,7 @@ struct DebugAnimationSettingsView: View {
Button("Done") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.Debug.animationDoneButton)
}
}
}
@@ -217,6 +218,7 @@ struct AnimationCard: View {
)
.scaleEffect(isPressed ? 0.95 : (isSelected ? 1.02 : 1.0))
}
.accessibilityIdentifier(AccessibilityID.Debug.animationCard(type.rawValue))
.buttonStyle(PlainButtonStyle())
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
withAnimation(.easeInOut(duration: 0.15)) {
@@ -336,6 +338,7 @@ struct DebugVotingContentView: View {
.fill(mood.color.opacity(0.15))
)
}
.accessibilityIdentifier(AccessibilityID.Debug.debugMoodButton(mood.strValue))
}
}

View File

@@ -58,6 +58,7 @@ struct LiveActivityPreviewView: View {
.background(Color.gray.opacity(0.2))
.cornerRadius(12)
}
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityResetButton)
Button(action: toggleAnimation) {
Label(isAnimating ? "Pause" : "Start", systemImage: isAnimating ? "pause.fill" : "play.fill")
@@ -68,6 +69,7 @@ struct LiveActivityPreviewView: View {
.foregroundColor(.white)
.cornerRadius(12)
}
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityToggleButton)
}
Button(action: { showRecordingMode = true }) {
@@ -79,6 +81,7 @@ struct LiveActivityPreviewView: View {
.foregroundColor(.white)
.cornerRadius(12)
}
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityRecordButton)
}
.padding(.horizontal, 20)
.padding(.bottom, 40)
@@ -264,6 +267,7 @@ struct LiveActivityRecordingView: View {
.background(Color.orange)
.foregroundColor(.white)
.cornerRadius(12)
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityDismissButton)
} else if isExporting {
Text("Exporting frames...")
.font(.title2.bold())
@@ -282,6 +286,7 @@ struct LiveActivityRecordingView: View {
}
}
}
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityExportButton)
.onTapGesture {
if !isExporting && !exportComplete {
startExport()
@@ -319,7 +324,9 @@ struct LiveActivityRecordingView: View {
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
exportPath = outputDir.path
#if DEBUG
print("📁 Exporting frames to: \(exportPath)")
#endif
let target = targetStreak
let outDir = outputDir
@@ -354,7 +361,9 @@ struct LiveActivityRecordingView: View {
await MainActor.run {
exportComplete = true
#if DEBUG
print("✅ Export complete! \(target) frames saved to: \(outPath)")
#endif
}
}
}

View File

@@ -7,7 +7,6 @@
import SwiftUI
#if DEBUG
struct PaywallPreviewSettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedStyle: PaywallStyle = .celestial
@@ -35,6 +34,7 @@ struct PaywallPreviewSettingsView: View {
Button("Done") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.Debug.paywallPreviewDoneButton)
}
}
.sheet(isPresented: $showFullPreview) {
@@ -160,6 +160,7 @@ struct PaywallPreviewSettingsView: View {
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.accessibilityIdentifier(AccessibilityID.Debug.viewFullPaywallButton)
}
private var gradientColors: [Color] {
@@ -241,6 +242,7 @@ struct StyleOptionRow: View {
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
}
.accessibilityIdentifier(AccessibilityID.Debug.paywallStyleOption(style.displayName))
.buttonStyle(.plain)
}
@@ -925,4 +927,3 @@ struct JournalMiniPreview: View {
.environmentObject(IAPManager())
}
}
#endif

View File

@@ -255,6 +255,7 @@ struct WhyUpgradeView: View {
Button("Done") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.Settings.doneButton)
}
}
}

View File

@@ -9,6 +9,10 @@ import SwiftUI
import UniformTypeIdentifiers
import StoreKit
private enum SettingsAnimationConstants {
static let locationPermissionCheckDelay: TimeInterval = 1.0
}
// MARK: - Settings Content View (for use in SettingsTabView)
struct SettingsContentView: View {
@EnvironmentObject var authManager: BiometricAuthManager
@@ -68,7 +72,6 @@ struct SettingsContentView: View {
privacyButton
analyticsToggle
#if DEBUG
// Debug section
debugSectionHeader
addTestDataButton
@@ -83,9 +86,9 @@ struct SettingsContentView: View {
exportInsightsButton
generateAndExportButton
deleteHealthKitDataButton
generateWeeklyDigestButton
clearDataButton
#endif
Spacer()
.frame(height: 20)
@@ -149,6 +152,7 @@ struct SettingsContentView: View {
}
.padding()
})
.accessibilityIdentifier(AccessibilityID.Settings.reminderTimeButton)
.accessibilityLabel(String(localized: "Reminder Time"))
.accessibilityValue(formattedReminderTime)
.accessibilityHint(String(localized: "Opens time picker to change reminder time"))
@@ -206,7 +210,6 @@ struct SettingsContentView: View {
// MARK: - Debug Section
#if DEBUG
private var debugSectionHeader: some View {
HStack {
Text("Debug")
@@ -269,6 +272,7 @@ struct SettingsContentView: View {
showTrialDatePicker = true
}
.font(.subheadline.weight(.medium))
.accessibilityIdentifier(AccessibilityID.Settings.changeTrialDateButton)
}
.padding()
}
@@ -283,6 +287,7 @@ struct SettingsContentView: View {
displayedComponents: .date
)
.datePickerStyle(.graphical)
.accessibilityIdentifier(AccessibilityID.Settings.trialDatePicker)
.padding()
.navigationTitle("Set Trial Start Date")
.navigationBarTitleDisplayMode(.inline)
@@ -295,6 +300,7 @@ struct SettingsContentView: View {
await iapManager.checkSubscriptionStatus()
}
}
.accessibilityIdentifier(AccessibilityID.Settings.trialDatePickerDoneButton)
}
}
}
@@ -338,6 +344,7 @@ struct SettingsContentView: View {
}
.padding()
}
.accessibilityIdentifier(AccessibilityID.Settings.paywallPreviewButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -381,6 +388,7 @@ struct SettingsContentView: View {
}
.padding()
}
.accessibilityIdentifier(AccessibilityID.Settings.tipsPreviewButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -418,6 +426,7 @@ struct SettingsContentView: View {
}
.padding()
}
.accessibilityIdentifier(AccessibilityID.Settings.testNotificationsButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -430,7 +439,9 @@ struct SettingsContentView: View {
widgetExportPath = await WidgetExporter.exportAllWidgets()
isExportingWidgets = false
if let path = widgetExportPath {
#if DEBUG
print("📸 Widgets exported to: \(path.path)")
#endif
openInFilesApp(path)
}
}
@@ -470,6 +481,7 @@ struct SettingsContentView: View {
.padding()
}
.disabled(isExportingWidgets)
.accessibilityIdentifier(AccessibilityID.Settings.exportWidgetsButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -482,7 +494,9 @@ struct SettingsContentView: View {
votingLayoutExportPath = await WidgetExporter.exportAllVotingLayouts()
isExportingVotingLayouts = false
if let path = votingLayoutExportPath {
#if DEBUG
print("📸 Voting layouts exported to: \(path.path)")
#endif
openInFilesApp(path)
}
}
@@ -522,6 +536,7 @@ struct SettingsContentView: View {
.padding()
}
.disabled(isExportingVotingLayouts)
.accessibilityIdentifier(AccessibilityID.Settings.exportVotingLayoutsButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -534,7 +549,9 @@ struct SettingsContentView: View {
watchExportPath = await WatchExporter.exportAllWatchViews()
isExportingWatchViews = false
if let path = watchExportPath {
#if DEBUG
print("⌚ Watch views exported to: \(path.path)")
#endif
openInFilesApp(path)
}
}
@@ -574,6 +591,7 @@ struct SettingsContentView: View {
.padding()
}
.disabled(isExportingWatchViews)
.accessibilityIdentifier(AccessibilityID.Settings.exportWatchViewsButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -586,7 +604,9 @@ struct SettingsContentView: View {
insightsExportPath = await InsightsExporter.exportInsightsScreenshots()
isExportingInsights = false
if let path = insightsExportPath {
#if DEBUG
print("✨ Insights exported to: \(path.path)")
#endif
openInFilesApp(path)
}
}
@@ -632,6 +652,7 @@ struct SettingsContentView: View {
.padding()
}
.disabled(isExportingInsights)
.accessibilityIdentifier(AccessibilityID.Settings.exportInsightsButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -645,7 +666,9 @@ struct SettingsContentView: View {
sharingExportPath = await SharingScreenshotExporter.exportAllSharingScreenshots()
isGeneratingScreenshots = false
if let path = sharingExportPath {
#if DEBUG
print("📸 Sharing screenshots exported to: \(path.path)")
#endif
openInFilesApp(path)
}
}
@@ -691,6 +714,7 @@ struct SettingsContentView: View {
.padding()
}
.disabled(isGeneratingScreenshots)
.accessibilityIdentifier(AccessibilityID.Settings.generateScreenshotsButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -741,6 +765,64 @@ struct SettingsContentView: View {
.padding()
}
.disabled(isDeletingHealthKitData)
.accessibilityIdentifier(AccessibilityID.Settings.deleteHealthKitButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
@State private var isGeneratingDigest = false
@State private var digestResult: String?
private var generateWeeklyDigestButton: some View {
Button {
isGeneratingDigest = true
digestResult = nil
Task {
if #available(iOS 26, *) {
do {
let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest()
digestResult = "\(digest.headline)"
} catch {
digestResult = "\(error.localizedDescription)"
}
} else {
digestResult = "✗ Requires iOS 26+"
}
isGeneratingDigest = false
}
} label: {
HStack(spacing: 12) {
if isGeneratingDigest {
ProgressView()
.frame(width: 32)
} else {
Image(systemName: "sparkles.rectangle.stack")
.font(.title2)
.foregroundColor(.purple)
.frame(width: 32)
}
VStack(alignment: .leading, spacing: 2) {
Text("Generate Weekly Digest")
.foregroundColor(textColor)
if let result = digestResult {
Text(result)
.font(.caption)
.foregroundColor(result.contains("") ? .green : .red)
} else {
Text("Create AI digest now (shows in Insights tab)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding()
}
.disabled(isGeneratingDigest)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -774,7 +856,6 @@ struct SettingsContentView: View {
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
#endif
// MARK: - Privacy Lock Toggle
@@ -852,11 +933,12 @@ struct SettingsContentView: View {
}
.padding()
}
.accessibilityIdentifier(AccessibilityID.Settings.addTestDataButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var healthKitToggle: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
@@ -902,7 +984,9 @@ struct SettingsContentView: View {
AnalyticsManager.shared.track(.healthKitNotAuthorized)
}
} catch {
#if DEBUG
print("HealthKit authorization failed: \(error)")
#endif
AnalyticsManager.shared.track(.healthKitEnableFailed)
}
}
@@ -1000,7 +1084,7 @@ struct SettingsContentView: View {
LocationManager.shared.requestAuthorization()
// Check if permission was denied after a brief delay
Task {
try? await Task.sleep(for: .seconds(1))
try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay))
let status = LocationManager.shared.authorizationStatus
if status == .denied || status == .restricted {
weatherEnabled = false
@@ -1048,7 +1132,9 @@ struct SettingsContentView: View {
UIApplication.shared.open(url)
}
}
.accessibilityIdentifier(AccessibilityID.Settings.locationAlertOpenSettingsButton)
Button(String(localized: "Cancel"), role: .cancel) {}
.accessibilityIdentifier(AccessibilityID.Settings.locationAlertCancelButton)
} message: {
Text("Reflect needs location access to show weather. You can enable it in Settings.")
}
@@ -1084,6 +1170,7 @@ struct SettingsContentView: View {
}
.padding()
})
.accessibilityIdentifier(AccessibilityID.Settings.exportDataButton)
.accessibilityLabel(String(localized: "Export Data"))
.accessibilityHint(String(localized: "Export your mood data as CSV or PDF"))
.background(theme.currentTheme.secondaryBGColor)
@@ -1377,11 +1464,11 @@ struct SettingsView: View {
// specialThanksCell
}
#if DEBUG
Group {
Divider()
Text("Test builds only")
Toggle("Bypass Subscription", isOn: $iapManager.bypassSubscription)
.accessibilityIdentifier(AccessibilityID.Settings.bypassSubscriptionToggle)
addTestDataCell
clearDB
// fixWeekday
@@ -1392,7 +1479,6 @@ struct SettingsView: View {
Divider()
}
Spacer()
#endif
Text("\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))")
.font(.body)
}
@@ -1427,9 +1513,13 @@ struct SettingsView: View {
switch result {
case .success(let url):
AnalyticsManager.shared.track(.dataExported(format: "file", count: 0))
#if DEBUG
print("Saved to \(url)")
#endif
case .failure(let error):
#if DEBUG
print(error.localizedDescription)
#endif
}
})
.fileImporter(isPresented: $showingImporter, allowedContentTypes: [.text],
@@ -1470,8 +1560,10 @@ struct SettingsView: View {
} catch {
// Handle failure.
AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription))
#if DEBUG
print("Unable to read file contents")
print(error.localizedDescription)
#endif
}
}
}
@@ -1611,7 +1703,9 @@ struct SettingsView: View {
AnalyticsManager.shared.track(.healthKitNotAuthorized)
}
} catch {
#if DEBUG
print("HealthKit authorization failed: \(error)")
#endif
AnalyticsManager.shared.track(.healthKitEnableFailed)
}
}
@@ -1701,7 +1795,7 @@ struct SettingsView: View {
LocationManager.shared.requestAuthorization()
// Check if permission was denied after a brief delay
Task {
try? await Task.sleep(for: .seconds(1))
try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay))
let status = LocationManager.shared.authorizationStatus
if status == .denied || status == .restricted {
weatherEnabled = false
@@ -1745,7 +1839,9 @@ struct SettingsView: View {
UIApplication.shared.open(url)
}
}
.accessibilityIdentifier(AccessibilityID.Settings.locationAlertOpenSettingsButton)
Button(String(localized: "Cancel"), role: .cancel) {}
.accessibilityIdentifier(AccessibilityID.Settings.locationAlertCancelButton)
} message: {
Text("Reflect needs location access to show weather. You can enable it in Settings.")
}
@@ -1781,6 +1877,7 @@ struct SettingsView: View {
}
.padding()
})
.accessibilityIdentifier(AccessibilityID.Settings.exportDataButton)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -1797,6 +1894,7 @@ struct SettingsView: View {
.font(.body)
.foregroundColor(Color(UIColor.systemBlue))
})
.accessibilityIdentifier(AccessibilityID.Settings.closeButton)
}
}
@@ -1811,17 +1909,20 @@ struct SettingsView: View {
Text(String(localized: "settings_view_special_thanks_to_title"))
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.specialThanksButton)
.padding()
if showSpecialThanks {
Divider()
Link("Font Awesome", destination: URL(string: "https://fontawesome.com")!)
.accessibilityIdentifier(AccessibilityID.Settings.fontAwesomeLink)
.accentColor(textColor)
.padding(.bottom)
Divider()
Link("Charts", destination: URL(string: "https://github.com/danielgindi/Charts")!)
.accessibilityIdentifier(AccessibilityID.Settings.chartsLink)
.accentColor(textColor)
.padding(.bottom)
}
@@ -1838,6 +1939,7 @@ struct SettingsView: View {
Text("Add test data")
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.addTestDataButton)
.padding()
.frame(maxWidth: .infinity)
.background(theme.currentTheme.secondaryBGColor)
@@ -1867,6 +1969,7 @@ struct SettingsView: View {
showTrialDatePicker = true
}
.font(.subheadline.weight(.medium))
.accessibilityIdentifier(AccessibilityID.Settings.changeTrialDateButton)
}
.padding()
.background(theme.currentTheme.secondaryBGColor)
@@ -1880,6 +1983,7 @@ struct SettingsView: View {
displayedComponents: .date
)
.datePickerStyle(.graphical)
.accessibilityIdentifier(AccessibilityID.Settings.trialDatePicker)
.padding()
.navigationTitle("Set Trial Start Date")
.navigationBarTitleDisplayMode(.inline)
@@ -1892,6 +1996,7 @@ struct SettingsView: View {
await iapManager.checkSubscriptionStatus()
}
}
.accessibilityIdentifier(AccessibilityID.Settings.trialDatePickerDoneButton)
}
}
}
@@ -1909,6 +2014,7 @@ struct SettingsView: View {
Text("Reset luanch date to current date")
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.resetLaunchDateButton)
.padding()
.frame(maxWidth: .infinity)
.background(theme.currentTheme.secondaryBGColor)
@@ -1923,6 +2029,7 @@ struct SettingsView: View {
Text("Clear DB")
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.clearDataButton)
.padding()
.frame(maxWidth: .infinity)
.background(theme.currentTheme.secondaryBGColor)
@@ -1937,6 +2044,7 @@ struct SettingsView: View {
Text("Fix Weekday")
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.fixWeekdayButton)
.padding()
.frame(maxWidth: .infinity)
.background(theme.currentTheme.secondaryBGColor)
@@ -1954,6 +2062,7 @@ struct SettingsView: View {
Text(String(localized: "settings_view_why_bg_mode_title"))
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.whyBackgroundModeButton)
.padding()
if showWhyBGMode {
Text(String(localized: "settings_view_why_bg_mode_body"))
@@ -2110,13 +2219,14 @@ struct SettingsView: View {
Text("Export")
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.exportLegacyButton)
.padding()
.frame(maxWidth: .infinity)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var importData: some View {
Button(action: {
showingImporter.toggle()
@@ -2125,13 +2235,14 @@ struct SettingsView: View {
Text("Import")
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.importButton)
.padding()
.frame(maxWidth: .infinity)
.background(theme.currentTheme.secondaryBGColor)
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var randomIcons: some View {
Button(action: {
var iconViews = [UIImage]()
@@ -2245,9 +2356,13 @@ struct SettingsView: View {
let url = URL(fileURLWithPath: path)
do {
try image.jpegData(compressionQuality: 1.0)?.write(to: url, options: .atomic)
#if DEBUG
print(url)
#endif
} catch {
#if DEBUG
print(error.localizedDescription)
#endif
}
}
@@ -2255,6 +2370,7 @@ struct SettingsView: View {
Text("Create random icons")
.foregroundColor(textColor)
})
.accessibilityIdentifier(AccessibilityID.Settings.randomIconsButton)
.padding()
.frame(maxWidth: .infinity)
.background(theme.currentTheme.secondaryBGColor)

View File

@@ -154,10 +154,10 @@ struct SharingListView: View {
}, label: {
ZStack {
theme.currentTheme.secondaryBGColor
item.preview
.frame(height: 88)
VStack {
Spacer()
Text(item.description)
@@ -179,6 +179,7 @@ struct SharingListView: View {
.contentShape(Rectangle())
.padding([.leading, .trailing])
})
.accessibilityIdentifier(AccessibilityID.Sharing.templateButton(item.description))
}
}

View File

@@ -52,6 +52,7 @@ struct SharingStylePickerView: View {
.font(.headline)
.foregroundColor(.red)
}
.accessibilityIdentifier(AccessibilityID.Sharing.exitButton)
Spacer()
@@ -104,6 +105,7 @@ struct SharingStylePickerView: View {
.background(Color.green)
.cornerRadius(14)
}
.accessibilityIdentifier(AccessibilityID.Sharing.shareButton)
.padding(.horizontal, 24)
.padding(.top, 12)
.padding(.bottom, 24)
@@ -160,6 +162,7 @@ struct LongestStreakPickerView: View {
.font(.headline)
.foregroundColor(.red)
}
.accessibilityIdentifier(AccessibilityID.Sharing.exitButton)
Spacer()
@@ -169,6 +172,7 @@ struct LongestStreakPickerView: View {
selectedMood = mood
recomputeStreak()
}
.accessibilityIdentifier(AccessibilityID.Sharing.moodMenuButton(mood.strValue))
}
} label: {
HStack(spacing: 6) {
@@ -180,6 +184,7 @@ struct LongestStreakPickerView: View {
.foregroundColor(textColor.opacity(0.6))
}
}
.accessibilityIdentifier(AccessibilityID.Sharing.moodMenu)
Spacer()
@@ -225,6 +230,7 @@ struct LongestStreakPickerView: View {
.background(Color.green)
.cornerRadius(14)
}
.accessibilityIdentifier(AccessibilityID.Sharing.shareButton)
.padding(.horizontal, 24)
.padding(.top, 12)
.padding(.bottom, 24)

View File

@@ -176,12 +176,13 @@ struct AllMoodsTotalTemplate: View, SharingTemplate {
.foregroundColor(Color.white)
.padding(.top, 20)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.shareButton)
.frame(maxWidth: .infinity, alignment: .center)
.background(
Color.green
)
.padding(.trailing, -5)
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
@@ -191,6 +192,7 @@ struct AllMoodsTotalTemplate: View, SharingTemplate {
.foregroundColor(Color.white)
.padding(.top, 20)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.dismissButton)
.frame(maxWidth: .infinity, alignment: .center)
.background(
Color.red

View File

@@ -119,6 +119,7 @@ struct CurrentStreakTemplate: View, SharingTemplate {
.foregroundColor(Color.white)
.padding(.top, 20)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.shareButton)
.sheet(isPresented: self.$shareImage.showSheet) {
if let uiImage = self.shareImage.selectedShareImage {
ShareSheet(photo: uiImage)
@@ -129,7 +130,7 @@ struct CurrentStreakTemplate: View, SharingTemplate {
Color.green
)
.padding(.trailing, -5)
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
@@ -139,6 +140,7 @@ struct CurrentStreakTemplate: View, SharingTemplate {
.foregroundColor(Color.white)
.padding(.top, 20)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.dismissButton)
.frame(maxWidth: .infinity, alignment: .center)
.background(
Color.red

View File

@@ -167,6 +167,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
selectedMood = mood
configureData(fakeData: self.fakeData, mood: self.selectedMood)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.moodMenuButton(mood.strValue))
}
}, label: {
Text("Pick Mood")
@@ -174,6 +175,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
.foregroundColor(textColor)
.padding()
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.moodMenu)
.frame(maxWidth: .infinity, alignment: .center)
.background(
theme.currentTheme.secondaryBGColor
@@ -194,6 +196,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
.foregroundColor(Color.white)
.padding(.top, 20)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.shareButton)
.sheet(isPresented: self.$shareImage.showSheet) {
if let uiImage = self.shareImage.selectedShareImage {
ShareSheet(photo: uiImage)
@@ -204,7 +207,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
Color.green
)
.padding(.trailing, -5)
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
@@ -214,6 +217,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
.foregroundColor(Color.white)
.padding(.top, 20)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.dismissButton)
.frame(maxWidth: .infinity, alignment: .center)
.background(
Color.red

View File

@@ -159,6 +159,7 @@ struct MonthTotalTemplate: View, SharingTemplate {
.foregroundColor(Color.white)
.padding(.top, 20)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.shareButton)
.sheet(isPresented: self.$shareImage.showSheet) {
if let uiImage = self.shareImage.selectedShareImage {
ShareSheet(photo: uiImage)
@@ -169,7 +170,7 @@ struct MonthTotalTemplate: View, SharingTemplate {
Color.green
)
.padding(.trailing, -5)
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
@@ -179,6 +180,7 @@ struct MonthTotalTemplate: View, SharingTemplate {
.foregroundColor(Color.white)
.padding(.top, 20)
})
.accessibilityIdentifier(AccessibilityID.SharingTemplate.dismissButton)
.frame(maxWidth: .infinity, alignment: .center)
.background(
Color.red

View File

@@ -93,6 +93,7 @@ struct SwitchableView: View {
theme.currentTheme.secondaryBGColor
)
.contentShape(Rectangle())
.accessibilityIdentifier(AccessibilityID.SwitchableView.headerToggle)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
.padding(.bottom, 30)
.onTapGesture {
@@ -100,6 +101,8 @@ struct SwitchableView: View {
self.headerTypeChanged(viewType)
AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: viewType)))
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Switch header view"))
}
}

View File

@@ -140,6 +140,7 @@ struct TipModalView: View {
y: 6
)
}
.accessibilityIdentifier(AccessibilityID.TipModal.dismissButton)
.padding(.horizontal, 24)
.padding(.bottom, 24)
.opacity(appeared ? 1 : 0)
@@ -245,7 +246,6 @@ extension View {
// MARK: - Tips Preview View (Debug)
#if DEBUG
struct TipsPreviewView: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedTipIndex: Int?
@@ -308,6 +308,7 @@ struct TipsPreviewView: View {
}
.padding(.vertical, 4)
}
.accessibilityIdentifier(AccessibilityID.TipModal.tipPreviewButton(index))
}
} header: {
Text("Tap to preview")
@@ -320,11 +321,13 @@ struct TipsPreviewView: View {
ReflectTipsManager.shared.resetAllTips()
}
.foregroundColor(.red)
.accessibilityIdentifier(AccessibilityID.TipModal.resetTipsButton)
Toggle("Tips Enabled", isOn: Binding(
get: { ReflectTipsManager.shared.tipsEnabled },
set: { ReflectTipsManager.shared.tipsEnabled = $0 }
))
.accessibilityIdentifier(AccessibilityID.TipModal.tipsEnabledToggle)
} header: {
Text("Settings")
}
@@ -346,6 +349,7 @@ struct TipsPreviewView: View {
Button("Done") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.TipModal.doneButton)
}
}
.sheet(item: Binding(
@@ -379,4 +383,3 @@ private struct TipIndexWrapper: Identifiable {
TipsPreviewView()
}
}
#endif

View File

@@ -308,6 +308,7 @@ struct YearView: View {
.preferredColorScheme(theme.preferredColorScheme)
#if DEBUG
// Triple-tap to toggle demo mode for video recording
.accessibilityIdentifier(AccessibilityID.YearView.debugDemoToggle)
.onTapGesture(count: 3) {
if demoManager.isDemoMode {
demoManager.stopDemoMode()

View File

@@ -0,0 +1,233 @@
# Guided Reflection — CBT-Aligned Adaptive Questioning Plan
## Context
The current guided reflection flow (`GuidedReflection.swift`, `GuidedReflectionView.swift`) asks 3-4 static questions based on mood category (positive → Behavioral Activation, neutral → ACT Defusion, negative → CBT Thought Record). Questions do not reference prior answers, do not adapt to cognitive distortions, and skip the evidence-examination step that is the actual mechanism of change in CBT.
This plan makes the reflection **more CBT-aligned, not less**, by introducing adaptive sequencing — which is the defining characteristic of Socratic questioning and guided discovery in CBT. Every phase is additive and ships independently.
---
## Phase 1 — Template Substitution + Intensity + Translation Fix
No AI dependency. Works offline. Fully localizable.
### 1a. Reference previous answers in question text
Update `GuidedReflection.questions(for:)` to return **question templates** with placeholders, then resolve them at render time using the user's prior answers.
**Files:**
- `Shared/Models/GuidedReflection.swift` — add `QuestionTemplate` struct with `template: String` and `placeholderRef: Int?` (index of question whose answer to inject)
- `Shared/Views/GuidedReflectionView.swift` — resolve templates against `draft.steps` when building the `Step.question` text at navigation time (not init time, so Q3 shows Q2's answer even if user goes back and edits)
- `Reflect/Localizable.xcstrings` — add new localized keys for templated questions using standard `%@` format specifier so each locale controls where the quoted answer appears grammatically
**Example — negative path:**
```
Q1 template: "What happened today that affected your mood?"
Q2 template: "What thought kept coming back about it?"
Q3 template: "If a friend told you they had the thought '%@', what would you tell them?" [inject Q2 answer]
Q4 template: "Looking at '%@' again — what's a more balanced way to see it?" [inject Q2 answer]
```
**Answer truncation:** if the referenced answer is > 60 characters, truncate to the first sentence or 60 chars + "…". Keep a helper `GuidedReflection.truncatedForInjection(_:)` in the model.
**Edge cases:**
- If referenced answer is empty (user skipped back), fall back to the current static question text.
- If user edits an earlier answer, the later question text updates on next navigation to it.
### 1b. Add emotion intensity rating (pre and post)
CBT measures emotional intensity before and after the thought work. This is the single most CBT-faithful addition.
**Files:**
- `Shared/Models/GuidedReflection.swift` — add `preIntensity: Int?` (0-10) and `postIntensity: Int?` (0-10) to `GuidedReflection` struct. Update `CodingKeys` and `isComplete` logic.
- `Shared/Views/GuidedReflectionView.swift` — render an intensity slider before Q1 (pre) and after the last question (post). Use a 0-10 discrete scale with labels ("barely", "intense") localized.
- `Shared/Services/FoundationModelsReflectionService.swift` — include `preIntensity` and `postIntensity` in the prompt so AI feedback can reference the shift ("you moved from an 8 to a 5").
### 1c. Fix stale localized question strings
The German, Spanish, French, Japanese, Korean, and Portuguese-BR translations in `Localizable.xcstrings` for `guided_reflection_{negative,neutral,positive}_q{1..4}` translate **older** English question text. Example: German Q1 is "Was belastet dich heute?" ("What's weighing on you?") but English is "What happened today that affected your mood?".
**File:** `Reflect/Localizable.xcstrings`
Retranslate all existing guided reflection question keys to match current English text. Flag state as `translated` only after review.
### 1d. Specificity probe on Q1
If the Q1 answer is < 25 characters or exactly matches a vague-phrase list (e.g., "idk", "everything", "nothing", "same as always"), surface a soft follow-up bubble below the editor: "Can you remember a specific moment? What happened just before you noticed the feeling?" Non-blocking — user can ignore it.
**Files:**
- `Shared/Views/GuidedReflectionView.swift` — add `needsSpecificityProbe(for:)` helper and conditional hint view below the Q1 editor
- `Reflect/Localizable.xcstrings` — add `guided_reflection_specificity_probe` key
---
## Phase 2 — Rule-Based Distortion Detection (Negative Path)
No AI dependency. Adds the most-impactful CBT mechanism: matching the reframe to the specific cognitive distortion.
### 2a. Distortion detection
Classify the Q2 answer into a cognitive distortion type using localized keyword rules.
**New file:** `Shared/Services/CognitiveDistortionDetector.swift`
```swift
enum CognitiveDistortion: String, Codable {
case overgeneralization // "always", "never", "everyone", "no one"
case shouldStatement // "should", "must", "have to"
case labeling // "I am [negative trait]"
case personalization // "my fault", "because of me"
case catastrophizing // "will never", "ruined", "can't recover"
case mindReading // "thinks I'm", "hates me", "judging me"
case unknown
}
@MainActor
enum CognitiveDistortionDetector {
static func detect(in text: String, locale: Locale = .current) -> CognitiveDistortion
}
```
Per-locale keyword lists live in localized strings (`distortion_overgeneralization_keywords` = comma-separated list). This stays localizable and avoids hardcoding English-only logic.
### 2b. Distortion-specific Q3 reframe prompt
Update the negative-path Q3 question resolution to switch on the detected distortion:
| Distortion | Q3 prompt (localized key) |
|---|---|
| overgeneralization | "Can you think of one counter-example to '%@'?" |
| shouldStatement | "Where did the rule 'I should …' come from? Is it still serving you?" |
| labeling | "Is '%@' something you *are*, or something you *did*?" |
| personalization | "What other factors, besides you, contributed to this?" |
| catastrophizing | "What's the worst case? What's the most likely case?" |
| mindReading | "What evidence do you have for that interpretation? What else could it mean?" |
| unknown | Current static Q3 (fallback) |
**Files:**
- `Shared/Models/GuidedReflection.swift` — add `detectedDistortion: CognitiveDistortion?` to persist the classification on the response
- `Shared/Views/GuidedReflectionView.swift` — call detector when transitioning from Q2 → Q3, pick template, render
- `Reflect/Localizable.xcstrings` — add 6 new localized question templates
### 2c. Add an evidence-examination step (negative path only)
Currently the negative path skips the core CBT Thought Record mechanism: examining evidence for/against the thought. Insert a new step between the current Q3 and Q4.
New flow for negative (5 questions instead of 4):
1. Situation (Q1)
2. Automatic thought (Q2)
3. Perspective check (Q3 — distortion-specific from 2b)
4. **Evidence examination (NEW Q4)**: "What evidence supports this thought, and what challenges it?"
5. Balanced reframe (Q5, formerly Q4)
**Files:**
- `Shared/Models/GuidedReflection.swift` — bump `MoodCategory.negative.questionCount` to 5, update `stepLabels`, update `questions(for:)`
- `Reflect/Localizable.xcstrings` — add `guided_reflection_negative_q_evidence` key (localized to all 7 languages)
- Migration: existing saved reflections with 4 responses still `isComplete` — use version-tolerant decoding (already Codable, but verify no crash on old JSON)
---
## Phase 3 — AI-Enhanced Final Question (Premium, iOS 26+)
Use Foundation Models to generate a personalized final reframe question based on the entire reflection so far. Falls back to Phase 2 rule-based prompt if AI unavailable.
### 3a. Adaptive final-question service
**New file:** `Shared/Services/FoundationModelsReflectionPrompterService.swift`
```swift
@available(iOS 26, *)
@MainActor
class FoundationModelsReflectionPrompterService {
func generateFinalQuestion(
moodCategory: MoodCategory,
priorResponses: [GuidedReflection.Response],
detectedDistortion: CognitiveDistortion?
) async throws -> String
}
```
System instructions enforce:
- One question only, under 25 words
- Must reference at least one specific phrase from a prior answer
- Must follow CBT principles (Socratic, non-leading, non-interpretive)
- Must map to the active therapeutic framework (Thought Record / ACT / BA)
Use `LanguageModelSession` with a constrained `Generable` output schema (just `{ question: String }`).
### 3b. Integration
- Gate behind `IAPManager.shared.shouldShowPaywall == false && iOS 26 && Apple Intelligence available`
- On transition to the final step, kick off generation with a 1.5s timeout. If timeout or error, fall back to the Phase 2 deterministic question.
- Show a brief "generating your question…" shimmer on the step card during generation — but pre-populate with the fallback text so the user can start reading/typing immediately if they want.
- Persist which question text was actually shown on `GuidedReflection.Response.question` so the AI feedback stage sees what the user actually saw.
### 3c. Update `FoundationModelsReflectionService`
Enhance the existing feedback service to reference:
- The intensity shift (pre → post)
- Which cognitive distortion was detected (if any)
- The fact that the final question was AI-adapted to them
---
## Files Modified / Created
### Modified
- `Shared/Models/GuidedReflection.swift` — templates, intensity, distortion, evidence step
- `Shared/Views/GuidedReflectionView.swift` — resolve templates, intensity sliders, specificity probe, distortion routing, AI prompt integration
- `Shared/Services/FoundationModelsReflectionService.swift` — consume intensity shift + distortion in feedback prompt
- `Reflect/Localizable.xcstrings` — retranslate existing keys + add ~15 new ones
### New
- `Shared/Services/CognitiveDistortionDetector.swift` (Phase 2)
- `Shared/Services/FoundationModelsReflectionPrompterService.swift` (Phase 3)
### Tests
- `ReflectTests/GuidedReflectionTemplatingTests.swift` — template resolution, answer truncation, edge cases (empty/edited prior answer)
- `ReflectTests/CognitiveDistortionDetectorTests.swift` — per-distortion detection with English fixtures (extend to other locales when translations land)
- `ReflectTests/GuidedReflectionMigrationTests.swift` — decode old 4-question JSON without crashing, handle missing intensity fields
---
## Verification
### Phase 1
1. Log a negative mood, start reflection
2. Answer Q1 with a specific event ("My boss criticized my presentation")
3. Answer Q2 with a thought ("I'm not cut out for this job")
4. Navigate to Q3 — verify the question text quotes the Q2 answer
5. Go back to Q2, change the answer, navigate forward — verify Q3 text updates
6. Verify pre-intensity slider appears before Q1 and post-intensity appears after the last question
7. Change device language to German — verify all question templates render grammatically correct German with quoted answers
8. Answer Q1 with "idk" — verify specificity probe appears
9. Answer Q1 with a full sentence — verify no probe
### Phase 2
1. Answer Q2 with "I always mess everything up" — verify Q3 shows the overgeneralization-specific prompt ("Can you think of one counter-example to...")
2. Answer Q2 with "I should have done better" — verify shouldStatement prompt
3. Answer Q2 with "I'm such a failure" — verify labeling prompt
4. Answer Q2 with a neutral thought (no distortion keywords) — verify fallback to the static Q3
5. Verify negative path now has 5 steps (progress shows 1/5)
6. Load an existing saved negative reflection with 4 responses — verify it still opens without crashing and shows as complete
### Phase 3
1. On iOS 26 device with Apple Intelligence + active subscription: complete Q1-Q4, navigate to Q5 — verify AI-generated question references specific wording from earlier answers
2. Turn off Apple Intelligence — verify fallback to Phase 2 deterministic question (no delay, no error banner)
3. On iOS 25 or non-subscribed user — verify Phase 2 prompt renders immediately (no AI path attempted)
4. Verify AI feedback at the end of the reflection references the intensity shift and (if detected) the cognitive distortion
### Cross-phase
- Run `xcodebuild test -only-testing:"ReflectTests"` — all new tests pass
- Manual run through all 3 mood categories (positive / neutral / negative) on English + 1 non-English locale
- Verify existing saved reflections from before this change still decode and display correctly
---
## Out of Scope
- Restructuring the positive (BA) or neutral (ACT) paths beyond Phase 1 templating. Those frameworks don't use distortion detection or evidence examination — their mechanisms are activity scheduling and values clarification, which work fine with static questions + templating.
- Changing chip suggestions. The current chip library is solid and orthogonal to this work.
- Personality-pack variants of the distortion prompts. Phase 2 ships with the "Default" voice only; other packs can be layered later using the same infrastructure.

File diff suppressed because it is too large Load Diff

154
stats.md Normal file
View File

@@ -0,0 +1,154 @@
# Advanced Statistics — Deep Data Research
## Temporal Pattern Mining
### Mood Cycles & Seasonality
- **Weekly cycles** — not just "best/worst day" but the actual shape of the week (do they dip mid-week and recover Friday, or crash on Sunday night?)
- **Monthly cycles** — mood patterns across the month (beginning vs end, paycheck timing effects)
- **Seasonal patterns** — spring vs winter mood baselines. Weather data can separate "it's cold" from "it's January" effects
- **Time-of-day patterns** — `timestamp` (when they logged) vs `forDate`. Late-night loggers vs morning loggers may show different patterns. Logging time itself could correlate with mood.
### Trend Decomposition
Instead of just "improving/declining/stable", decompose the mood signal into:
- **Baseline** (long-term average that shifts slowly)
- **Trend** (is the baseline rising or falling over months?)
- **Volatility** (are swings getting wider or narrower over time?)
This gives users a real answer to "am I actually getting better?" that a simple average can't.
---
## Cross-Signal Correlations
### Health × Mood (Per-User Correlation Ranking)
9 health metrics available. Instead of showing all, **rank which health signals matter most for THIS specific user**. Compute per-user Pearson correlation between each health metric and mood:
- "Sleep is your #1 mood predictor (r=0.72)"
- "Steps have no significant correlation for you (r=0.08)"
- "Your HRV and mood are moderately linked (r=0.45)"
Personalized and genuinely useful — tells each user what to focus on.
### Weather × Mood (Beyond Averages)
Instead of just "sunny days = happier":
- **Temperature sweet spot** — fit a curve to find their optimal temperature range
- **Weather transitions** — does a sunny day *after* three rainy days hit differently than a sunny day in a sunny streak?
- **Humidity as a factor** — stored but not analyzed
### Tags × Health × Mood (Multivariate)
Cross-signal analysis:
- "On days tagged 'work' + sleep < 6hrs, your mood averages 1.8. On 'work' + sleep > 7hrs, it's 3.4" — sleep is a buffer against work stress
- "Exercise days tagged 'social' average 4.2, exercise days tagged 'solo' average 3.1" — social exercise matters more
---
## Behavioral Pattern Analysis
### Logging Behavior as Signal
The *act of logging* contains information:
- **Entry source patterns** — do they use the widget more on bad days? Watch on good days? Could reveal avoidance patterns
- **Logging time drift** — are they logging later and later? Often correlates with declining mood
- **Note length vs mood** — do they write more when upset or when happy? `notes?.count` is free data
- **Reflection completion rate** — do they bail on guided reflections for certain moods? Completing a negative reflection may itself be therapeutic
### Gap Analysis (Deeper)
Beyond simple gap tracking:
- **What predicts a gap?** Look at the 3 days before each gap — was mood declining? Were they on a negative streak?
- **Recovery patterns** — how long after returning does mood stabilize? Is there a "bounce" effect?
- **Gap frequency over time** — are they getting more or less consistent? Consistency trend is a health proxy
---
## AI-Enriched Analysis
### Note/Reflection Sentiment Trends
- **Sentiment trajectory within a reflection** — does the user start negative and end positive (processing) or start positive and end negative (rumination)?
- **Topic evolution** — what themes are growing vs fading over months? "Work" mentions peaking = potential burnout signal
- **Gratitude frequency** — entries tagged "gratitude" tracked as a percentage over time. Research shows gratitude journaling improves wellbeing — show them their own trend
### Predicted Mood
With enough data (30+ entries), build a simple predictor:
- Given today's day of week, recent weather, recent sleep, and current streak — what mood is likely?
- Show as a "forecast" card: "Based on your patterns, Tuesdays after poor sleep tend to be tough — be gentle with yourself"
- Uses correlations already computed, just applied forward
---
## Comparative & Benchmark Insights
### Personal Bests & Records
- Longest positive streak ever (and when it was)
- Best week/month on record
- Most consistent month (lowest variance)
- "Your mood this March was your best March in 2 years"
### Milestone Detection
- "You've logged 100 days"
- "Your 30-day average just hit an all-time high"
- "First month with no 'horrible' days"
- Motivational and drives retention
### Before/After Analysis
If a user starts a new habit (e.g., enables HealthKit, starts guided reflections, starts tagging), compare stats before vs after:
- "Since you started doing guided reflections 45 days ago, your average mood is up 0.6 points"
- "Since enabling Health tracking, your logging consistency improved 23%"
---
## Feasibility Notes
All of this runs on data already collected. The compute is lightweight:
- Correlations are just `zip` + arithmetic on two arrays
- Cycle detection is grouping by `weekDay` / `Calendar.component(.month)` / hour-of-day
- Trend decomposition is a sliding window average
- Predictions are weighted averages of correlated factors
- No server needed — Foundation Models handles the narrative, Swift handles the math
The heavy lift is **visualization** (Swift Charts) and **narrative framing** (using Foundation Models to turn "r=0.72 for sleep" into "Sleep is your superpower — on nights you get 7+ hours, your mood jumps by a full point").
---
## Existing Data Points Available
### Per Entry (MoodEntryModel)
1. Date logged (`forDate`)
2. Mood value (5-point scale)
3. Entry type (10 sources: app, widget, watch, siri, etc.)
4. Timestamp created
5. Day of week
6. Text notes (optional)
7. Photo ID (optional)
8. Weather data — condition, temp high/low, humidity, location (optional)
9. Guided reflection responses (optional)
10. AI-extracted tags from 16 categories (optional)
### HealthKit (9 metrics)
- Steps, exercise minutes, active calories, distance
- Average heart rate, resting heart rate, HRV
- Sleep hours, mindful minutes
### Already Computed (MoodDataSummarizer)
- Mood distribution (counts, percentages, averages)
- Day-of-week averages, best/worst day, weekend vs weekday
- Trend direction and magnitude
- Streaks (current, longest, positive, negative)
- Mood stability score and swing count
- Tag-mood correlations (good-day tags, bad-day tags)
- Weather-mood averages (by condition, by temp range)
- Logging gap analysis (pre/post gap averages)
- Entry source breakdown
### Already Visualized
- Year heatmap + donut chart (YearView)
- AI-generated text insights (InsightsView)
- Weekly digest card (WeeklyDigestCardView)
- AI reports with PDF export (ReportsView)
### NOT Yet Visualized (Gaps)
- No trend line charts
- No health correlation charts
- No tag/theme visualizations
- No period comparisons
- No streak visualizations beyond a number
- No mood stability visualization
- No logging behavior analysis
- No predictive features