Compare commits

...

10 Commits

Author SHA1 Message Date
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
89 changed files with 23192 additions and 18638 deletions

View File

@@ -22,8 +22,9 @@ struct ContentView: View {
// Show voting UI // Show voting UI
VStack(spacing: 8) { VStack(spacing: 8) {
Text("How do you feel?") Text("How do you feel?")
.font(.system(size: 16, weight: .medium)) .font(.headline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.accessibilityAddTraits(.isHeader)
// Top row: Great, Good, Average // Top row: Great, Good, Average
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -87,11 +88,14 @@ struct AlreadyRatedView: View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text(mood.watchEmoji) Text(mood.watchEmoji)
.font(.system(size: 50)) .font(.system(size: 50))
.accessibilityHidden(true)
Text("Logged!") Text("Logged!")
.font(.system(size: 18, weight: .semibold)) .font(.title3.weight(.semibold))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(mood.strValue) mood logged"))
} }
} }
@@ -104,13 +108,16 @@ struct MoodButton: View {
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
Text(mood.watchEmoji) Text(mood.watchEmoji)
.font(.system(size: 28)) .font(.title2)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 50) .frame(height: 50)
.background(mood.watchColor.opacity(0.3)) .background(mood.watchColor.opacity(0.3))
.cornerRadius(12) .cornerRadius(12)
} }
.buttonStyle(.plain) .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> <array>
<string>com.88oakapps.reflect.dbUpdateMissing</string> <string>com.88oakapps.reflect.dbUpdateMissing</string>
<string>com.88oakapps.reflect.weatherRetry</string> <string>com.88oakapps.reflect.weatherRetry</string>
<string>com.88oakapps.reflect.weeklyDigest</string>
</array> </array>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>Reflect uses your location to show weather details for your mood entries.</string> <string>Reflect uses your location to show weather details for your mood entries.</string>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -158,10 +158,13 @@ struct VotedStatsView: View {
Circle() Circle()
.fill(moodTint.color(forMood: mood)) .fill(moodTint.color(forMood: mood))
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
.accessibilityHidden(true)
Text("\(count)") Text("\(count)")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.accessibilityElement(children: .combine)
.accessibilityLabel("\(count) \(mood.strValue)")
} }
} }
} }
@@ -214,6 +217,7 @@ struct NonSubscriberView: View {
} }
.accessibilityLabel(String(localized: "Track Your Mood")) .accessibilityLabel(String(localized: "Track Your Mood"))
.accessibilityHint(String(localized: "Tap to open app and subscribe")) .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) { VStack(spacing: 0) {
// Top 50%: Text left-aligned, vertically centered // Top 50%: Text left-aligned, vertically centered
HStack { HStack {
Text(hasSubscription ? promptText : "Subscribe to track your mood") Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
.font(.system(size: 20, weight: .semibold)) .font(.title3.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(2) .lineLimit(2)
@@ -93,6 +93,7 @@ struct VotingView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood")) .accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else { } else {
Link(destination: URL(string: "reflect://subscribe")!) { Link(destination: URL(string: "reflect://subscribe")!) {
moodIcon(for: mood, size: size) moodIcon(for: mood, size: size)
@@ -100,6 +101,7 @@ struct VotingView: View {
} }
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe")) .accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
} }
} }
@@ -119,12 +121,14 @@ struct VotingView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood")) .accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else { } else {
Link(destination: URL(string: "reflect://subscribe")!) { Link(destination: URL(string: "reflect://subscribe")!) {
content content
} }
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe")) .accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
} }
} }
@@ -155,8 +159,8 @@ struct LargeVotingView: View {
GeometryReader { geo in GeometryReader { geo in
VStack(spacing: 0) { VStack(spacing: 0) {
// Top 33%: Title centered // Top 33%: Title centered
Text(hasSubscription ? promptText : "Subscribe to track your mood") Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
.font(.system(size: 24, weight: .semibold)) .font(.title2.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(2) .lineLimit(2)
@@ -196,12 +200,14 @@ struct LargeVotingView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood")) .accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else { } else {
Link(destination: URL(string: "reflect://subscribe")!) { Link(destination: URL(string: "reflect://subscribe")!) {
moodButtonContent(for: mood) moodButtonContent(for: mood)
} }
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe")) .accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
} }
} }
@@ -261,12 +267,14 @@ struct InlineVotingView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood")) .accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else { } else {
Link(destination: URL(string: "reflect://subscribe")!) { Link(destination: URL(string: "reflect://subscribe")!) {
moodIcon(for: mood) moodIcon(for: mood)
} }
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe")) .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" static let baLearnMoreLink = "guided_reflection_ba_learn_more"
} }
// MARK: - Reflection Feedback
enum ReflectionFeedback {
static let container = "reflection_feedback_container"
static let loading = "reflection_feedback_loading"
static let content = "reflection_feedback_content"
static let fallback = "reflection_feedback_fallback"
static let doneButton = "reflection_feedback_done"
}
// MARK: - Weekly Digest
enum WeeklyDigest {
static let card = "weekly_digest_card"
static let dismissButton = "weekly_digest_dismiss"
}
// MARK: - Settings // MARK: - Settings
enum Settings { enum Settings {
static let header = "settings_header" static let header = "settings_header"
@@ -120,6 +135,45 @@ enum AccessibilityID {
static let reminderTimePicker = "settings_reminder_time_picker" static let reminderTimePicker = "settings_reminder_time_picker"
static let reminderSaveButton = "settings_reminder_save" static let reminderSaveButton = "settings_reminder_save"
static let reminderCancelButton = "settings_reminder_cancel" 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 // MARK: - Customize
@@ -129,6 +183,7 @@ enum AccessibilityID {
static let appThemePickerDoneButton = "apptheme_picker_done" static let appThemePickerDoneButton = "apptheme_picker_done"
static let appThemePreviewCancelButton = "apptheme_preview_cancel" static let appThemePreviewCancelButton = "apptheme_preview_cancel"
static let appThemePreviewApplyButton = "apptheme_preview_apply" static let appThemePreviewApplyButton = "apptheme_preview_apply"
static let widgetHowToLink = "customize_widget_how_to_link"
static func themeButton(_ name: String) -> String { static func themeButton(_ name: String) -> String {
"customize_theme_\(name.lowercased())" "customize_theme_\(name.lowercased())"
} }
@@ -147,6 +202,31 @@ enum AccessibilityID {
static func appThemeCard(_ name: String) -> String { static func appThemeCard(_ name: String) -> String {
"apptheme_card_\(name.lowercased())" "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 // MARK: - Paywall
@@ -173,21 +253,32 @@ enum AccessibilityID {
static let monthSection = "insights_month_section" static let monthSection = "insights_month_section"
static let yearSection = "insights_year_section" static let yearSection = "insights_year_section"
static let allTimeSection = "insights_all_time_section" static let allTimeSection = "insights_all_time_section"
static let expandCollapseButton = "insights_expand_collapse"
} }
// MARK: - Month View // MARK: - Month View
enum MonthView { enum MonthView {
static let grid = "month_grid" static let grid = "month_grid"
static let shareButton = "month_share_button" 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 // MARK: - Month Detail
enum MonthDetail { enum MonthDetail {
static let shareButton = "month_detail_share" static let shareButton = "month_detail_share"
static let deleteButton = "month_detail_delete" static let deleteButton = "month_detail_delete"
static let cancelButton = "month_detail_cancel"
static func moodButton(_ mood: String) -> String { static func moodButton(_ mood: String) -> String {
"month_detail_mood_\(mood.lowercased())" "month_detail_mood_\(mood.lowercased())"
} }
static func entryCell(_ dateString: String) -> String {
"month_detail_entry_\(dateString)"
}
} }
// MARK: - Year View // MARK: - Year View
@@ -198,6 +289,7 @@ enum AccessibilityID {
static let statsSection = "year_stats_section" static let statsSection = "year_stats_section"
static func cardHeader(year: Int) -> String { "year_card_header_\(year)" } static func cardHeader(year: Int) -> String { "year_card_header_\(year)" }
static let shareButton = "year_share_button" static let shareButton = "year_share_button"
static let debugDemoToggle = "year_debug_demo_toggle"
} }
// MARK: - Onboarding // MARK: - Onboarding
@@ -213,12 +305,23 @@ enum AccessibilityID {
static let subscribeButton = "onboarding_subscribe_button" static let subscribeButton = "onboarding_subscribe_button"
static let skipButton = "onboarding_skip_button" static let skipButton = "onboarding_skip_button"
static let nextButton = "onboarding_next_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 // MARK: - Reports
enum Reports { enum Reports {
static let segmentedPicker = "reports_segmented_picker" static let segmentedPicker = "reports_segmented_picker"
static let dateRangePicker = "reports_date_range_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 quickSummaryButton = "reports_quick_summary_button"
static let detailedReportButton = "reports_detailed_report_button" static let detailedReportButton = "reports_detailed_report_button"
static let generateButton = "reports_generate_button" static let generateButton = "reports_generate_button"
@@ -229,6 +332,8 @@ enum AccessibilityID {
static let minimumEntriesWarning = "reports_minimum_entries_warning" static let minimumEntriesWarning = "reports_minimum_entries_warning"
static let exportDataButton = "reports_export_data_button" static let exportDataButton = "reports_export_data_button"
static let retryButton = "reports_retry_button" static let retryButton = "reports_retry_button"
static let privacyShareButton = "reports_privacy_share"
static let privacyCancelButton = "reports_privacy_cancel"
} }
// MARK: - Purchase / Subscription // MARK: - Purchase / Subscription
@@ -239,6 +344,11 @@ enum AccessibilityID {
static let subscribeButton = "purchase_subscribe" static let subscribeButton = "purchase_subscribe"
} }
// MARK: - Subscription Store
enum SubscriptionStore {
static let closeButton = "subscription_store_close"
}
// MARK: - IAP Warning // MARK: - IAP Warning
enum IAPWarning { enum IAPWarning {
static let subscribeButton = "iap_warning_subscribe" static let subscribeButton = "iap_warning_subscribe"
@@ -249,6 +359,7 @@ enum AccessibilityID {
static let unlockButton = "lock_screen_unlock" static let unlockButton = "lock_screen_unlock"
static let tryAgainButton = "lock_screen_try_again" static let tryAgainButton = "lock_screen_try_again"
static let cancelButton = "lock_screen_cancel" static let cancelButton = "lock_screen_cancel"
static let passcodeUnlockButton = "lock_screen_passcode_unlock"
static func passcodeButton(_ digit: Int) -> String { static func passcodeButton(_ digit: Int) -> String {
"lock_screen_passcode_\(digit)" "lock_screen_passcode_\(digit)"
} }
@@ -260,6 +371,135 @@ enum AccessibilityID {
static let dismissArea = "full_screen_photo_dismiss" 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 // MARK: - Common
enum Common { enum Common {
static let lockScreen = "lock_screen" static let lockScreen = "lock_screen"

View File

@@ -11,6 +11,7 @@ import BackgroundTasks
class BGTask { class BGTask {
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing" static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry" static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
static let weeklyDigestID = "com.88oakapps.reflect.weeklyDigest"
@MainActor @MainActor
class func runFillInMissingDatesTask(task: BGProcessingTask) { class func runFillInMissingDatesTask(task: BGProcessingTask) {
@@ -51,7 +52,68 @@ class BGTask {
do { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
} catch { } catch {
#if DEBUG
print("Could not schedule weather retry: \(error)") 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 { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
} catch { } catch {
#if DEBUG
print("Could not schedule image fetch: \(error)") print("Could not schedule image fetch: \(error)")
#endif
} }
} }
} }

View File

@@ -134,7 +134,6 @@ extension Color {
} }
extension Color: @retroactive RawRepresentable { extension Color: @retroactive RawRepresentable {
// TODO: Sort out alpha
public init?(rawValue: Int) { public init?(rawValue: Int) {
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
let green = Double((rawValue & 0x00FF00) >> 8) / 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) /// 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 /// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription
#if DEBUG
@Published var bypassSubscription: Bool { @Published var bypassSubscription: Bool {
didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") } didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") }
} }
#else
let bypassSubscription = false
#endif
// MARK: - Constants // MARK: - Constants
@@ -140,9 +136,7 @@ class IAPManager: ObservableObject {
// MARK: - Initialization // MARK: - Initialization
init() { init() {
#if DEBUG
self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription") self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription")
#endif
restoreCachedSubscriptionState() restoreCachedSubscriptionState()
updateListenerTask = listenForTransactions() updateListenerTask = listenForTransactions()
@@ -307,8 +301,16 @@ class IAPManager: ObservableObject {
// Get renewal info // Get renewal info
if let product = currentProduct, if let product = currentProduct,
let subscription = product.subscription, let subscription = product.subscription {
let statuses = try? await subscription.status { 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 var hadVerifiedStatus = false
for status in statuses { for status in statuses {
@@ -365,7 +367,6 @@ class IAPManager: ObservableObject {
return false return false
} }
#if DEBUG
/// Reset subscription state for UI testing. Called after group defaults are cleared /// Reset subscription state for UI testing. Called after group defaults are cleared
/// so that stale cached state from previous test runs is discarded. /// so that stale cached state from previous test runs is discarded.
func resetForTesting() { func resetForTesting() {
@@ -382,7 +383,6 @@ class IAPManager: ObservableObject {
updateTrialState() updateTrialState()
} }
#endif
private func updateTrialState() { private func updateTrialState() {
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 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) let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: trigger)
UNUserNotificationCenter.current().add(request) { (error : Error?) in UNUserNotificationCenter.current().add(request) { (error : Error?) in
if let theError = error { if let theError = error {
#if DEBUG
print(theError.localizedDescription) print(theError.localizedDescription)
#endif
} }
} }
case .failure(let error): case .failure(let error):
#if DEBUG
print(error) print(error)
#endif
// Todo: show enable this // Todo: show enable this
break break
} }
@@ -135,7 +139,28 @@ class LocalNotification {
// MARK: - Debug: Send All Personality Pack Notifications // 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 /// Sends one notification from each personality pack, staggered over 10 seconds for screenshot
public class func sendAllPersonalityNotificationsForScreenshot() { public class func sendAllPersonalityNotificationsForScreenshot() {
let _ = createNotificationCategory() 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

@@ -80,24 +80,24 @@ struct QuestionChips {
// MARK: Positive (Great/Good) Behavioral Activation // MARK: Positive (Great/Good) Behavioral Activation
// Q1: "What did you do today?" no chips (situational) // 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): case (.positive, 1):
return QuestionChips( return QuestionChips(
topRow: [ topRow: [
String(localized: "guided_chip_pos_joy"), String(localized: "guided_chip_pos_moment_conversation"),
String(localized: "guided_chip_pos_gratitude"), String(localized: "guided_chip_pos_moment_accomplished"),
String(localized: "guided_chip_pos_pride"), String(localized: "guided_chip_pos_moment_calm"),
String(localized: "guided_chip_pos_contentment"), String(localized: "guided_chip_pos_moment_laugh"),
String(localized: "guided_chip_pos_love"), String(localized: "guided_chip_pos_moment_grateful_person"),
String(localized: "guided_chip_pos_excitement"), String(localized: "guided_chip_pos_moment_small_win"),
], ],
expanded: [ expanded: [
String(localized: "guided_chip_pos_inspiration"), String(localized: "guided_chip_pos_moment_beauty"),
String(localized: "guided_chip_pos_amusement"), String(localized: "guided_chip_pos_moment_connected"),
String(localized: "guided_chip_pos_serenity"), String(localized: "guided_chip_pos_moment_progress"),
String(localized: "guided_chip_pos_relief"), String(localized: "guided_chip_pos_moment_like_myself"),
String(localized: "guided_chip_pos_connection"), String(localized: "guided_chip_pos_moment_kindness"),
String(localized: "guided_chip_pos_hope"), String(localized: "guided_chip_pos_moment_time_well_spent"),
] ]
) )
@@ -221,22 +221,24 @@ struct QuestionChips {
expanded: [] expanded: []
) )
// Q4: "More balanced way to see it?" grounding actions + cognitive shifts // Q4: "More balanced way to see it?" cognitive reframes first, grounding actions expanded
case (.negative, 3): case (.negative, 3):
return QuestionChips( return QuestionChips(
topRow: [ 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_talk_someone"),
String(localized: "guided_chip_neg_act_write_it_out"), String(localized: "guided_chip_neg_act_write_it_out"),
String(localized: "guided_chip_neg_act_take_walk"), String(localized: "guided_chip_neg_act_take_walk"),
String(localized: "guided_chip_neg_act_step_away"), String(localized: "guided_chip_neg_act_step_away"),
String(localized: "guided_chip_neg_act_get_rest"), String(localized: "guided_chip_neg_act_get_rest"),
String(localized: "guided_chip_neg_act_one_small_thing"), 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"),
] ]
) )

View File

@@ -48,11 +48,28 @@ final class MoodEntryModel {
// Guided Reflection // Guided Reflection
var reflectionJSON: String? var reflectionJSON: String?
// AI-extracted theme tags (JSON array of strings)
var tagsJSON: String?
// Computed properties // Computed properties
var mood: Mood { var mood: Mood {
Mood(rawValue: moodValue) ?? .missing Mood(rawValue: moodValue) ?? .missing
} }
/// Decoded tags from tagsJSON, or empty array if none
var tags: [String] {
guard let json = tagsJSON, let data = json.data(using: .utf8),
let decoded = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return decoded
}
/// Whether this entry has AI-extracted tags
var hasTags: Bool {
!tags.isEmpty
}
var moodString: String { var moodString: String {
mood.strValue mood.strValue
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,6 +63,16 @@ extension DataController {
return true return true
} }
// MARK: - Tags
@discardableResult
func updateTags(forDate date: Date, tagsJSON: String?) -> Bool {
guard let entry = getEntry(byDate: date) else { return false }
entry.tagsJSON = tagsJSON
saveAndRunDataListeners()
return true
}
// MARK: - Photo // MARK: - Photo
@discardableResult @discardableResult

View File

@@ -12,6 +12,10 @@ import WidgetKit
@main @main
struct ReflectApp: App { struct ReflectApp: App {
private enum AnimationConstants {
static let deepLinkHandlingDelay: TimeInterval = 0.3
}
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@@ -40,6 +44,10 @@ struct ReflectApp: App {
guard let processingTask = task as? BGProcessingTask else { return } guard let processingTask = task as? BGProcessingTask else { return }
BGTask.runWeatherRetryTask(task: processingTask) BGTask.runWeatherRetryTask(task: processingTask)
} }
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.weeklyDigestID, using: nil) { task in
guard let processingTask = task as? BGProcessingTask else { return }
BGTask.runWeeklyDigestTask(task: processingTask)
}
UNUserNotificationCenter.current().setBadgeCount(0) UNUserNotificationCenter.current().setBadgeCount(0)
// Reset tips session on app launch // Reset tips session on app launch
@@ -73,6 +81,7 @@ struct ReflectApp: App {
.alert("Data Storage Unavailable", .alert("Data Storage Unavailable",
isPresented: $showStorageFallbackAlert) { isPresented: $showStorageFallbackAlert) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.AppAlert.storageUnavailableOK)
} message: { } message: {
Text("Your mood data cannot be saved permanently. Please restart the app. If the problem persists, reinstall the app.") 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 { if let url = AppDelegate.pendingDeepLinkURL {
AppDelegate.pendingDeepLinkURL = nil AppDelegate.pendingDeepLinkURL = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.deepLinkHandlingDelay) {
handleDeepLink(url) handleDeepLink(url)
} }
} }
@@ -97,6 +106,7 @@ struct ReflectApp: App {
}.onChange(of: scenePhase) { _, newPhase in }.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background { if newPhase == .background {
BGTask.scheduleBackgroundProcessing() BGTask.scheduleBackgroundProcessing()
BGTask.scheduleWeeklyDigest()
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
// Flush pending analytics events // Flush pending analytics events
AnalyticsManager.shared.flush() AnalyticsManager.shared.flush()

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,6 @@
// Exportable watch views that match the real watchOS layouts. // Exportable watch views that match the real watchOS layouts.
// These views accept tint/icon configuration as parameters for batch export. // These views accept tint/icon configuration as parameters for batch export.
// //
#if DEBUG
import SwiftUI import SwiftUI
// MARK: - Watch Export Configuration // MARK: - Watch Export Configuration
@@ -362,4 +360,3 @@ struct ExportableComplicationContainer<Content: View>: View {
.clipShape(isCircular ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: 12, style: .continuous))) .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. // Exportable widget views that match the real WidgetKit widgets pixel-for-pixel.
// These views accept tint/icon configuration as parameters for batch export. // These views accept tint/icon configuration as parameters for batch export.
// //
#if DEBUG
import SwiftUI import SwiftUI
// MARK: - Widget Theme Configuration // MARK: - Widget Theme Configuration
@@ -691,4 +689,3 @@ struct ExportableWidgetContainer<Content: View>: View {
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) .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 Foundation
import FoundationModels import FoundationModels
import os.log
/// Error types for insight generation /// Error types for insight generation
enum InsightGenerationError: Error, LocalizedError { 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 /// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
@available(iOS 26, *) @available(iOS 26, *)
@MainActor @MainActor
@@ -39,6 +49,7 @@ class FoundationModelsInsightService: ObservableObject {
@Published private(set) var isAvailable: Bool = false @Published private(set) var isAvailable: Bool = false
@Published private(set) var isGenerating: Bool = false @Published private(set) var isGenerating: Bool = false
@Published private(set) var lastError: InsightGenerationError? @Published private(set) var lastError: InsightGenerationError?
@Published private(set) var unavailableReason: AIUnavailableReason = .unknown
// MARK: - Dependencies // MARK: - Dependencies
@@ -62,15 +73,27 @@ class FoundationModelsInsightService: ObservableObject {
switch model.availability { switch model.availability {
case .available: case .available:
isAvailable = true isAvailable = true
unavailableReason = .unknown
case .unavailable(let reason): case .unavailable(let reason):
isAvailable = false isAvailable = false
unavailableReason = mapUnavailableReason(reason)
lastError = .modelUnavailable(reason: describeUnavailability(reason)) lastError = .modelUnavailable(reason: describeUnavailability(reason))
@unknown default: @unknown default:
isAvailable = false isAvailable = false
unavailableReason = .unknown
lastError = .modelUnavailable(reason: "Unknown availability status") 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 { private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
switch reason { switch reason {
case .deviceNotEligible: 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 { private func createSession() -> LanguageModelSession {
LanguageModelSession(instructions: systemInstructions) LanguageModelSession(instructions: systemInstructions)
} }
@@ -213,8 +242,7 @@ class FoundationModelsInsightService: ObservableObject {
throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available") throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available")
} }
// Create a new session for this request to allow concurrent generation let activeSession = createSession()
let session = createSession()
// Filter valid entries // Filter valid entries
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
@@ -231,9 +259,10 @@ class FoundationModelsInsightService: ObservableObject {
let prompt = buildPrompt(from: summary, count: count) let prompt = buildPrompt(from: summary, count: count)
do { do {
let response = try await session.respond( let response = try await activeSession.respond(
to: prompt, to: prompt,
generating: AIInsightsResponse.self generating: AIInsightsResponse.self,
options: GenerationOptions(maximumResponseTokens: 600)
) )
let insights = response.content.insights.map { $0.toInsight() } let insights = response.content.insights.map { $0.toInsight() }
@@ -244,9 +273,7 @@ class FoundationModelsInsightService: ObservableObject {
return insights return insights
} catch { } catch {
// Log detailed error for debugging // Log detailed error for debugging
print("AI Insight generation failed for '\(periodName)': \(error)") AppLogger.ai.error("AI Insight generation failed for '\(periodName)': \(error)")
print(" Error type: \(type(of: error))")
print(" Localized: \(error.localizedDescription)")
lastError = .generationFailed(underlying: error) lastError = .generationFailed(underlying: error)
throw lastError! throw lastError!
@@ -263,7 +290,7 @@ class FoundationModelsInsightService: ObservableObject {
\(dataSection) \(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,126 @@
//
// 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")
return """
The user logged their mood as "\(moodName)" and completed a \(technique) reflection:
\(qaPairs)
Respond with personalized feedback that references their specific answers.
"""
}
}

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

View File

@@ -5,9 +5,9 @@
// Debug utility to export insights view screenshots with sample AI data. // Debug utility to export insights view screenshots with sample AI data.
// //
#if DEBUG
import SwiftUI import SwiftUI
import UIKit import UIKit
import os.log
/// Exports insights view screenshots for App Store marketing /// Exports insights view screenshots for App Store marketing
@MainActor @MainActor
@@ -28,7 +28,12 @@ class InsightsExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) 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 var totalExported = 0
@@ -95,9 +100,12 @@ class InsightsExporter {
if let image = renderer.uiImage { if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png") let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() { 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) // Health data for AI analysis (optional)
let healthAverages: HealthService.HealthAverages? 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 /// Transforms raw MoodEntryModel data into AI-optimized summaries
@@ -83,6 +100,11 @@ class MoodDataSummarizer {
// Format date range // Format date range
let dateRange = formatDateRange(entries: sortedEntries) 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( return MoodDataSummary(
periodName: periodName, periodName: periodName,
totalEntries: validEntries.count, totalEntries: validEntries.count,
@@ -107,7 +129,16 @@ class MoodDataSummarizer {
last7DaysMoods: recentContext.moods, last7DaysMoods: recentContext.moods,
hasAllMoodTypes: moodTypes.hasAll, hasAllMoodTypes: moodTypes.hasAll,
missingMoodTypes: moodTypes.missing, 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) 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 // MARK: - Helpers
private func formatDateRange(entries: [MoodEntryModel]) -> String { private func formatDateRange(entries: [MoodEntryModel]) -> String {
@@ -384,7 +548,16 @@ class MoodDataSummarizer {
last7DaysMoods: [], last7DaysMoods: [],
hasAllMoodTypes: false, hasAllMoodTypes: false,
missingMoodTypes: ["great", "good", "average", "bad", "horrible"], 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.") 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") return lines.joined(separator: "\n")
} }
} }

View File

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

View File

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

View File

@@ -6,9 +6,9 @@
// Uses the exportable watch views from ExportableWatchViews.swift. // Uses the exportable watch views from ExportableWatchViews.swift.
// //
#if DEBUG
import SwiftUI import SwiftUI
import UIKit import UIKit
import os.log
/// Exports watch view previews to PNG files for App Store screenshots /// Exports watch view previews to PNG files for App Store screenshots
@MainActor @MainActor
@@ -76,7 +76,12 @@ class WatchExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) 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 var totalExported = 0
@@ -85,7 +90,12 @@ class WatchExporter {
for iconOption in allIcons { for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)" let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) 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( let config = WatchExportConfig(
moodTint: tintOption.tint, moodTint: tintOption.tint,
@@ -242,9 +252,12 @@ class WatchExporter {
if let image = renderer.uiImage { if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png") let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() { 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. // Uses the real widget view layouts from ExportableWidgetViews.swift.
// //
#if DEBUG
import SwiftUI import SwiftUI
import UIKit import UIKit
import os.log
/// Exports widget previews to PNG files for App Store screenshots /// Exports widget previews to PNG files for App Store screenshots
@MainActor @MainActor
@@ -76,7 +76,12 @@ class WidgetExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) 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 var totalExported = 0
@@ -85,7 +90,12 @@ class WidgetExporter {
for iconOption in allIcons { for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)" let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) 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( let config = WidgetExportConfig(
moodTint: tintOption.tint, moodTint: tintOption.tint,
@@ -155,7 +165,12 @@ class WidgetExporter {
let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true) let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true)
try? FileManager.default.removeItem(at: exportPath) 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( let config = WidgetExportConfig(
moodTint: UserDefaultsStore.moodTintable(), moodTint: UserDefaultsStore.moodTintable(),
@@ -177,7 +192,12 @@ class WidgetExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) 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 var totalExported = 0
@@ -186,7 +206,12 @@ class WidgetExporter {
for iconOption in allIcons { for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)" let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) 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( let config = WidgetExportConfig(
moodTint: tintOption.tint, moodTint: tintOption.tint,
@@ -372,7 +397,11 @@ class WidgetExporter {
if let image = renderer.uiImage { if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png") let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() { 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 { if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png") let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() { 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) .background(theme.currentTheme.secondaryBGColor)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier(AccessibilityID.DayView.moodHeader)
} }
@ViewBuilder @ViewBuilder
@@ -125,13 +124,13 @@ struct HorizontalVotingView: View {
} }
.buttonStyle(MoodButtonStyle()) .buttonStyle(MoodButtonStyle())
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName)) .accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood")) .accessibilityHint(String(localized: "Select this mood"))
} }
} }
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
} }
} }
@@ -167,8 +166,6 @@ struct CardVotingView: View {
} }
} }
.frame(height: 190) .frame(height: 190)
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
} }
private func cardButton(for mood: Mood, width: CGFloat) -> some View { private func cardButton(for mood: Mood, width: CGFloat) -> some View {
@@ -190,6 +187,8 @@ struct CardVotingView: View {
) )
} }
.buttonStyle(CardButtonStyle()) .buttonStyle(CardButtonStyle())
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName)) .accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood")) .accessibilityHint(String(localized: "Select this mood"))
@@ -230,13 +229,13 @@ struct StackedVotingView: View {
) )
} }
.buttonStyle(CardButtonStyle()) .buttonStyle(CardButtonStyle())
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName)) .accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood")) .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)) .buttonStyle(AuraButtonStyle(color: color))
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName)) .accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood")) .accessibilityHint(String(localized: "Select this mood"))
@@ -355,8 +356,6 @@ struct OrbitVotingView: View {
.onDisappear { .onDisappear {
centerPulse = 1.0 centerPulse = 1.0
} }
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
} }
private func orbitalRing(radius: CGFloat, centerX: CGFloat, centerY: CGFloat) -> some View { private func orbitalRing(radius: CGFloat, centerX: CGFloat, centerY: CGFloat) -> some View {
@@ -408,6 +407,8 @@ struct OrbitVotingView: View {
} }
.buttonStyle(OrbitButtonStyle(color: color)) .buttonStyle(OrbitButtonStyle(color: color))
.position(x: posX, y: posY) .position(x: posX, y: posY)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName)) .accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood")) .accessibilityHint(String(localized: "Select this mood"))
@@ -696,7 +697,9 @@ struct NeonEqualizerBar: View {
} }
.buttonStyle(NeonBarButtonStyle(isPressed: $isPressed)) .buttonStyle(NeonBarButtonStyle(isPressed: $isPressed))
.frame(maxWidth: .infinity) .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) .accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood")) .accessibilityHint(String(localized: "Select this mood"))
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -110,10 +110,12 @@ struct EntryListView: View {
if hasNotes { if hasNotes {
Image(systemName: "note.text") Image(systemName: "note.text")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
} }
if hasReflection { if hasReflection {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
} }
} }
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -134,7 +136,10 @@ struct EntryListView: View {
if isMissing { if isMissing {
return String(localized: "\(dateString), no mood logged") return String(localized: "\(dateString), no mood logged")
} else { } 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") { Button("Cancel") {
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.Export.cancelButton)
} }
} }
.sheet(isPresented: $showShareSheet) { .sheet(isPresented: $showShareSheet) {
@@ -113,6 +114,7 @@ struct ExportView: View {
} }
.alert("Export Failed", isPresented: $showError) { .alert("Export Failed", isPresented: $showError) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.Export.alertOKButton)
} message: { } message: {
Text(errorMessage) Text(errorMessage)
} }
@@ -230,6 +232,7 @@ struct ExportView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Export.formatButton(format.rawValue))
} }
} }
} }
@@ -260,6 +263,7 @@ struct ExportView: View {
.background(Color(.systemBackground)) .background(Color(.systemBackground))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Export.rangeButton(range.rawValue))
if range != DateRange.allCases.last { if range != DateRange.allCases.last {
Divider() Divider()
@@ -293,6 +297,7 @@ struct ExportView: View {
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
} }
.disabled(isExporting || validEntries.isEmpty) .disabled(isExporting || validEntries.isEmpty)
.accessibilityIdentifier(AccessibilityID.Export.exportButton)
.padding(.top, 8) .padding(.top, 8)
} }

View File

@@ -24,6 +24,8 @@ struct GuidedReflectionView: View {
@State private var isSaving = false @State private var isSaving = false
@State private var showDiscardAlert = false @State private var showDiscardAlert = false
@State private var showInfoSheet = false @State private var showInfoSheet = false
@State private var showFeedback = false
@State private var savedReflection: GuidedReflection?
private let initialDraft: GuidedReflectionDraft private let initialDraft: GuidedReflectionDraft
@@ -77,8 +79,24 @@ struct GuidedReflectionView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollViewReader { proxy in ZStack {
reflectionSheetContent(with: proxy) ScrollViewReader { proxy in
reflectionSheetContent(with: proxy)
}
.blur(radius: showFeedback ? 6 : 0)
.allowsHitTesting(!showFeedback)
if showFeedback, let savedReflection {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture { }
ReflectionFeedbackView(
mood: entry.mood,
reflection: savedReflection,
onDismiss: { dismiss() }
)
}
} }
} }
} }
@@ -236,6 +254,8 @@ struct GuidedReflectionView: View {
.frame(height: 10) .frame(height: 10)
} }
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "\(draft.steps.filter(\.hasAnswer).count) of \(draft.steps.count) steps completed"))
} }
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots) .accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
} }
@@ -454,7 +474,22 @@ struct GuidedReflectionView: View {
) )
if success { if success {
dismiss() // Fire-and-forget tag extraction
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall {
Task {
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
}
}
// Show AI feedback if reflection is complete and AI is potentially available
if reflection.isComplete {
savedReflection = reflection
withAnimation(.easeInOut(duration: 0.3)) {
showFeedback = true
}
} else {
dismiss()
}
} else { } else {
isSaving = false isSaving = false
} }

View File

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

View File

@@ -13,10 +13,15 @@ enum InsightsTab: String, CaseIterable {
} }
struct InsightsView: View { 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.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.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var scenePhase
private var textColor: Color { theme.currentTheme.labelColor } private var textColor: Color { theme.currentTheme.labelColor }
@@ -24,6 +29,8 @@ struct InsightsView: View {
@EnvironmentObject var iapManager: IAPManager @EnvironmentObject var iapManager: IAPManager
@State private var showSubscriptionStore = false @State private var showSubscriptionStore = false
@State private var selectedTab: InsightsTab = .insights @State private var selectedTab: InsightsTab = .insights
@State private var weeklyDigest: WeeklyDigest?
@State private var showDigest = true
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -40,6 +47,7 @@ struct InsightsView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.accessibilityHidden(true)
Text("AI") Text("AI")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
} }
@@ -82,6 +90,10 @@ struct InsightsView: View {
if iapManager.shouldShowPaywall { if iapManager.shouldShowPaywall {
paywallOverlay paywallOverlay
} }
if selectedTab == .insights && isGeneratingInsights && !iapManager.shouldShowPaywall {
generatingOverlay
}
} }
} }
.sheet(isPresented: $showSubscriptionStore) { .sheet(isPresented: $showSubscriptionStore) {
@@ -94,15 +106,52 @@ struct InsightsView: View {
.onAppear { .onAppear {
AnalyticsManager.shared.trackScreen(.insights) AnalyticsManager.shared.trackScreen(.insights)
viewModel.generateInsights() viewModel.generateInsights()
loadWeeklyDigest()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
viewModel.recheckAvailability()
}
} }
.padding(.top) .padding(.top)
} }
// MARK: - Insights Content // 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 { private var insightsContent: some View {
ScrollView { ScrollView {
VStack(spacing: 20) { 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 // This Month Section
InsightsSectionView( InsightsSectionView(
title: "This Month", title: "This Month",
@@ -145,14 +194,145 @@ struct InsightsView: View {
.padding(.vertical) .padding(.vertical)
.padding(.bottom, 100) .padding(.bottom, 100)
} }
.opacity(isGeneratingInsights && !iapManager.shouldShowPaywall ? 0.2 : 1.0)
.animation(.easeInOut(duration: 0.3), value: isGeneratingInsights)
.refreshable { .refreshable {
viewModel.refreshInsights() viewModel.refreshInsights()
// Small delay to show refresh animation // Small delay to show refresh animation
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(nanoseconds: AnimationConstants.refreshDelay)
} }
.disabled(iapManager.shouldShowPaywall) .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 || $0 == .idle })
}
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 // MARK: - Paywall Overlay
private var paywallOverlay: some View { private var paywallOverlay: some View {
@@ -173,6 +353,7 @@ struct InsightsView: View {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.largeTitle) .font(.largeTitle)
.accessibilityHidden(true)
.foregroundStyle( .foregroundStyle(
LinearGradient( LinearGradient(
colors: [.purple, .blue], colors: [.purple, .blue],
@@ -202,6 +383,7 @@ struct InsightsView: View {
} label: { } label: {
HStack { HStack {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.accessibilityHidden(true)
Text("Get Personal Insights") Text("Get Personal Insights")
} }
.font(.headline.weight(.bold)) .font(.headline.weight(.bold))
@@ -277,6 +459,7 @@ struct InsightsSectionView: View {
.padding(.vertical, 14) .padding(.vertical, 14)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Insights.expandCollapseButton)
.accessibilityAddTraits(.isHeader) .accessibilityAddTraits(.isHeader)
// Insights List (collapsible) // Insights List (collapsible)

View File

@@ -40,6 +40,7 @@ class InsightsViewModel: ObservableObject {
@Published var allTimeLoadingState: InsightLoadingState = .idle @Published var allTimeLoadingState: InsightLoadingState = .idle
@Published var isAIAvailable: Bool = false @Published var isAIAvailable: Bool = false
@Published var aiUnavailableReason: AIUnavailableReason = .preiOS26
// MARK: - Dependencies // MARK: - Dependencies
@@ -57,9 +58,12 @@ class InsightsViewModel: ObservableObject {
let service = FoundationModelsInsightService() let service = FoundationModelsInsightService()
insightService = service insightService = service
isAIAvailable = service.isAvailable isAIAvailable = service.isAvailable
aiUnavailableReason = service.unavailableReason
service.prewarm()
} else { } else {
insightService = nil insightService = nil
isAIAvailable = false isAIAvailable = false
aiUnavailableReason = .preiOS26
} }
dataListenerToken = DataController.shared.addNewDataListener { [weak self] in dataListenerToken = DataController.shared.addNewDataListener { [weak self] in
@@ -118,12 +122,23 @@ class InsightsViewModel: ObservableObject {
let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7]) 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]) 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)
}
}
// Generate insights concurrently for all three periods // Generate insights concurrently for all three periods
await withTaskGroup(of: Void.self) { group in await withTaskGroup(of: Void.self) { group in
group.addTask { @MainActor in group.addTask { @MainActor in
await self.generatePeriodInsights( await self.generatePeriodInsights(
entries: monthEntries, entries: monthEntries,
periodName: "this month", periodName: "this month",
healthAverages: sharedHealthAverages,
updateState: { self.monthLoadingState = $0 }, updateState: { self.monthLoadingState = $0 },
updateInsights: { self.monthInsights = $0 } updateInsights: { self.monthInsights = $0 }
) )
@@ -133,6 +148,7 @@ class InsightsViewModel: ObservableObject {
await self.generatePeriodInsights( await self.generatePeriodInsights(
entries: yearEntries, entries: yearEntries,
periodName: "this year", periodName: "this year",
healthAverages: sharedHealthAverages,
updateState: { self.yearLoadingState = $0 }, updateState: { self.yearLoadingState = $0 },
updateInsights: { self.yearInsights = $0 } updateInsights: { self.yearInsights = $0 }
) )
@@ -142,6 +158,7 @@ class InsightsViewModel: ObservableObject {
await self.generatePeriodInsights( await self.generatePeriodInsights(
entries: allTimeEntries, entries: allTimeEntries,
periodName: "all time", periodName: "all time",
healthAverages: sharedHealthAverages,
updateState: { self.allTimeLoadingState = $0 }, updateState: { self.allTimeLoadingState = $0 },
updateInsights: { self.allTimeInsights = $0 } updateInsights: { self.allTimeInsights = $0 }
) )
@@ -152,6 +169,7 @@ class InsightsViewModel: ObservableObject {
private func generatePeriodInsights( private func generatePeriodInsights(
entries: [MoodEntryModel], entries: [MoodEntryModel],
periodName: String, periodName: String,
healthAverages: HealthService.HealthAverages?,
updateState: @escaping (InsightLoadingState) -> Void, updateState: @escaping (InsightLoadingState) -> Void,
updateInsights: @escaping ([Insight]) -> Void updateInsights: @escaping ([Insight]) -> Void
) async { ) async {
@@ -170,27 +188,16 @@ class InsightsViewModel: ObservableObject {
return return
} }
// Check if AI is available // Check if AI is available show reason-specific guidance
guard isAIAvailable else { guard isAIAvailable else {
updateInsights([Insight( let (icon, title, description) = unavailableMessage()
icon: "brain.head.profile", updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)])
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
updateState(.error("AI not available")) updateState(.error("AI not available"))
return return
} }
updateState(.loading) 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 { if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
do { do {
let insights = try await service.generateInsights( let insights = try await service.generateInsights(
@@ -212,13 +219,47 @@ class InsightsViewModel: ObservableObject {
updateState(.error(error.localizedDescription)) updateState(.error(error.localizedDescription))
} }
} else { } else {
updateInsights([Insight( let (icon, title, description) = unavailableMessage()
icon: "brain.head.profile", updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)])
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
updateState(.error("AI not available")) 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)) .background(Color.accentColor.opacity(0.15))
.clipShape(Circle()) .clipShape(Circle())
} }
.accessibilityIdentifier(AccessibilityID.Reports.previousMonthButton)
.accessibilityLabel("Previous month") .accessibilityLabel("Previous month")
Spacer() Spacer()
@@ -172,6 +173,7 @@ struct ReportDateRangePicker: View {
.background(Color.accentColor.opacity(0.15)) .background(Color.accentColor.opacity(0.15))
.clipShape(Circle()) .clipShape(Circle())
} }
.accessibilityIdentifier(AccessibilityID.Reports.nextMonthButton)
.accessibilityLabel("Next month") .accessibilityLabel("Next month")
.disabled(isDisplayingCurrentMonth) .disabled(isDisplayingCurrentMonth)
} }
@@ -341,6 +343,7 @@ private struct ReportDayCell: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(isFuture) .disabled(isFuture)
.accessibilityIdentifier(AccessibilityID.Reports.dayCell(dateString: dayNumber))
.frame(height: 40) .frame(height: 40)
} }
} }

View File

@@ -95,7 +95,9 @@ struct ReportsView: View {
viewModel.exportDataPDF() viewModel.exportDataPDF()
} }
} }
.accessibilityIdentifier(AccessibilityID.Reports.privacyShareButton)
Button(String(localized: "Cancel"), role: .cancel) {} Button(String(localized: "Cancel"), role: .cancel) {}
.accessibilityIdentifier(AccessibilityID.Reports.privacyCancelButton)
} message: { } message: {
Text("This report contains your personal mood data and journal notes. Only share it with people you trust.") 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() let service = FoundationModelsInsightService()
insightService = service insightService = service
isAIAvailable = service.isAvailable isAIAvailable = service.isAvailable
service.prewarm()
// Also prewarm the clinical session used for reports
let clinicalSession = LanguageModelSession(instructions: clinicalSystemInstructions)
clinicalSession.prewarm()
} else { } else {
insightService = nil insightService = nil
isAIAvailable = false isAIAvailable = false
@@ -205,7 +209,7 @@ class ReportsViewModel: ObservableObject {
""" """
do { 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() } guard !Task.isCancelled else { throw CancellationError() }
@@ -251,10 +255,11 @@ class ReportsViewModel: ObservableObject {
let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count
var completedSections = 0 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, *) { 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) { for batchStart in stride(from: 0, to: weeks.count, by: batchSize) {
guard !Task.isCancelled else { throw CancellationError() } 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() } guard !Task.isCancelled else { throw CancellationError() }
progressMessage = String(localized: "Generating monthly summaries...") progressMessage = String(localized: "Generating monthly summaries...")
await withTaskGroup(of: (Int, String?).self) { group in for batchStart in stride(from: 0, to: monthlySummaries.count, by: batchSize) {
for (index, monthSummary) in monthlySummaries.enumerated() { guard !Task.isCancelled else { throw CancellationError() }
group.addTask { @MainActor in
let summary = await self.generateMonthlySummary(month: monthSummary, allEntries: reportEntries)
return (index, summary)
}
}
for await (index, summary) in group { let batchEnd = min(batchStart + batchSize, monthlySummaries.count)
monthlySummaries[index].aiSummary = summary let batchIndices = batchStart..<batchEnd
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...")
await withTaskGroup(of: (Int, String?).self) { group in await withTaskGroup(of: (Int, String?).self) { group in
for (index, yearSummary) in yearlySummaries.enumerated() { for index in batchIndices {
group.addTask { @MainActor in 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) return (index, summary)
} }
} }
for await (index, summary) in group { for await (index, summary) in group {
yearlySummaries[index].aiSummary = summary monthlySummaries[index].aiSummary = summary
completedSections += 1 completedSections += 1
progressValue = Double(completedSections) / Double(totalSections) 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( return MoodReport(
@@ -337,7 +356,6 @@ class ReportsViewModel: ObservableObject {
@available(iOS 26, *) @available(iOS 26, *)
private func generateWeeklySummary(week: ReportWeek) async -> String? { private func generateWeeklySummary(week: ReportWeek) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions) let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in
let day = entry.date.formatted(.dateTime.weekday(.abbreviated)) let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
let mood = entry.mood.widgetDisplayName let mood = entry.mood.widgetDisplayName
@@ -358,7 +376,7 @@ class ReportsViewModel: ObservableObject {
""" """
do { 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 return response.content.summary
} catch { } catch {
return "Summary unavailable" return "Summary unavailable"
@@ -368,7 +386,6 @@ class ReportsViewModel: ObservableObject {
@available(iOS 26, *) @available(iOS 26, *)
private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? { private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions) let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let monthEntries = allEntries.filter { let monthEntries = allEntries.filter {
calendar.component(.month, from: $0.date) == month.month && calendar.component(.month, from: $0.date) == month.month &&
calendar.component(.year, from: $0.date) == month.year calendar.component(.year, from: $0.date) == month.year
@@ -387,7 +404,7 @@ class ReportsViewModel: ObservableObject {
""" """
do { 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 return response.content.summary
} catch { } catch {
return "Summary unavailable" return "Summary unavailable"
@@ -397,7 +414,6 @@ class ReportsViewModel: ObservableObject {
@available(iOS 26, *) @available(iOS 26, *)
private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? { private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions) let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year } let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year }
let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) } let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) }
@@ -420,7 +436,7 @@ class ReportsViewModel: ObservableObject {
""" """
do { 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 return response.content.summary
} catch { } catch {
return "Summary unavailable" 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 // MARK: - Main Lock Screen View
struct LockScreenView: 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 @Environment(\.colorScheme) private var colorScheme
@ObservedObject var authManager: BiometricAuthManager @ObservedObject var authManager: BiometricAuthManager
@State private var showError = false @State private var showError = false
@@ -1691,6 +1697,7 @@ struct LockScreenView: View {
.disabled(authManager.isAuthenticating) .disabled(authManager.isAuthenticating)
.padding(.top, 16) .padding(.top, 16)
.opacity(showContent ? 1 : 0) .opacity(showContent ? 1 : 0)
.accessibilityIdentifier(AccessibilityID.LockScreen.passcodeUnlockButton)
.accessibilityLabel("Use device passcode") .accessibilityLabel("Use device passcode")
.accessibilityHint("Double tap to authenticate with your 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.") Text("Unable to verify your identity. Please try again.")
} }
.onAppear { .onAppear {
withAnimation(.easeOut(duration: 0.8).delay(0.2)) { withAnimation(.easeOut(duration: AnimationConstants.contentAppearDuration).delay(AnimationConstants.contentAppearDelay)) {
showContent = true showContent = true
} }
if !authManager.isUnlocked && !authManager.isAuthenticating { if !authManager.isUnlocked && !authManager.isAuthenticating {
Task { Task {
try? await Task.sleep(for: .milliseconds(800)) try? await Task.sleep(for: .milliseconds(AnimationConstants.authenticationDelay))
await authManager.authenticate() await authManager.authenticate()
} }
} }

View File

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

View File

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

View File

@@ -10,6 +10,10 @@ import PhotosUI
struct NoteEditorView: View { struct NoteEditorView: View {
private enum AnimationConstants {
static let keyboardAppearDelay: TimeInterval = 0.5
}
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@@ -57,18 +61,18 @@ struct NoteEditorView: View {
} }
.padding() .padding()
} }
.navigationTitle("Journal Note") .navigationTitle(String(localized: "Journal Note"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button(String(localized: "Cancel")) {
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton) .accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton)
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Save") { Button(String(localized: "Save")) {
saveNote() saveNote()
} }
.disabled(isSaving || noteText.count > maxCharacters) .disabled(isSaving || noteText.count > maxCharacters)
@@ -78,14 +82,14 @@ struct NoteEditorView: View {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {
Spacer() Spacer()
Button("Done") { Button(String(localized: "Done")) {
isTextFieldFocused = false isTextFieldFocused = false
} }
.accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton) .accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton)
} }
} }
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.keyboardAppearDelay) {
isTextFieldFocused = true isTextFieldFocused = true
} }
} }
@@ -129,6 +133,12 @@ struct NoteEditorView: View {
let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave) let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave)
if success { if success {
// Fire-and-forget tag extraction after saving a note
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall, noteToSave != nil {
Task {
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
}
}
dismiss() dismiss()
} else { } else {
isSaving = false isSaving = false
@@ -186,6 +196,11 @@ struct EntryDetailView: View {
// Mood section // Mood section
moodSection moodSection
// Tags section
if entry.hasTags {
tagsSection
}
// Guided reflection section // Guided reflection section
if currentMood != .missing && currentMood != .placeholder { if currentMood != .missing && currentMood != .placeholder {
reflectionSection reflectionSection
@@ -205,12 +220,12 @@ struct EntryDetailView: View {
.padding() .padding()
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("Entry Details") .navigationTitle(String(localized: "Entry Details"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.accessibilityIdentifier(AccessibilityID.EntryDetail.sheet) .accessibilityIdentifier(AccessibilityID.EntryDetail.sheet)
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Done") { Button(String(localized: "Done")) {
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton) .accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton)
@@ -222,16 +237,16 @@ struct EntryDetailView: View {
.sheet(isPresented: $showReflectionFlow) { .sheet(isPresented: $showReflectionFlow) {
GuidedReflectionView(entry: entry) GuidedReflectionView(entry: entry)
} }
.alert("Delete Entry", isPresented: $showDeleteConfirmation) { .alert(String(localized: "Delete Entry"), isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) { Button(String(localized: "Delete"), role: .destructive) {
onDelete() onDelete()
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton) .accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton)
Button("Cancel", role: .cancel) { } Button(String(localized: "Cancel"), role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton) .accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton)
} message: { } 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) .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
.onChange(of: selectedPhotoItem) { _, newItem in .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 { private var notesSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {

View File

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

View File

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

View File

@@ -46,6 +46,7 @@ struct ReflectSubscriptionStoreView: View {
} }
.padding(16) .padding(16)
.accessibilityLabel("Close") .accessibilityLabel("Close")
.accessibilityIdentifier(AccessibilityID.SubscriptionStore.closeButton)
} }
.onAppear { .onAppear {
AppLogger.iap.info("SubscriptionStoreView appeared — source: \(source), productIDs: \(IAPManager.productIdentifiers.sorted().joined(separator: ", ")), groupID: \(IAPManager.subscriptionGroupID)") 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() .resizable()
.frame(width: 20, height: 20, alignment: .trailing) .frame(width: 20, height: 20, alignment: .trailing)
.foregroundColor(Color(UIColor.systemGray)) .foregroundColor(Color(UIColor.systemGray))
.accessibilityIdentifier(AccessibilityID.SampleEntry.refreshButton)
.onTapGesture { .onTapGesture {
sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: sampleListEntry.mood.next) sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: sampleListEntry.mood.next)
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Refresh sample entry"))
} }
Spacer() Spacer()
}.padding() }.padding()

View File

@@ -54,6 +54,7 @@ struct DebugAnimationSettingsView: View {
Button("Done") { Button("Done") {
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.Debug.animationDoneButton)
} }
} }
} }
@@ -217,6 +218,7 @@ struct AnimationCard: View {
) )
.scaleEffect(isPressed ? 0.95 : (isSelected ? 1.02 : 1.0)) .scaleEffect(isPressed ? 0.95 : (isSelected ? 1.02 : 1.0))
} }
.accessibilityIdentifier(AccessibilityID.Debug.animationCard(type.rawValue))
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
withAnimation(.easeInOut(duration: 0.15)) { withAnimation(.easeInOut(duration: 0.15)) {
@@ -336,6 +338,7 @@ struct DebugVotingContentView: View {
.fill(mood.color.opacity(0.15)) .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)) .background(Color.gray.opacity(0.2))
.cornerRadius(12) .cornerRadius(12)
} }
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityResetButton)
Button(action: toggleAnimation) { Button(action: toggleAnimation) {
Label(isAnimating ? "Pause" : "Start", systemImage: isAnimating ? "pause.fill" : "play.fill") Label(isAnimating ? "Pause" : "Start", systemImage: isAnimating ? "pause.fill" : "play.fill")
@@ -68,6 +69,7 @@ struct LiveActivityPreviewView: View {
.foregroundColor(.white) .foregroundColor(.white)
.cornerRadius(12) .cornerRadius(12)
} }
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityToggleButton)
} }
Button(action: { showRecordingMode = true }) { Button(action: { showRecordingMode = true }) {
@@ -79,6 +81,7 @@ struct LiveActivityPreviewView: View {
.foregroundColor(.white) .foregroundColor(.white)
.cornerRadius(12) .cornerRadius(12)
} }
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityRecordButton)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 40) .padding(.bottom, 40)
@@ -264,6 +267,7 @@ struct LiveActivityRecordingView: View {
.background(Color.orange) .background(Color.orange)
.foregroundColor(.white) .foregroundColor(.white)
.cornerRadius(12) .cornerRadius(12)
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityDismissButton)
} else if isExporting { } else if isExporting {
Text("Exporting frames...") Text("Exporting frames...")
.font(.title2.bold()) .font(.title2.bold())
@@ -282,6 +286,7 @@ struct LiveActivityRecordingView: View {
} }
} }
} }
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityExportButton)
.onTapGesture { .onTapGesture {
if !isExporting && !exportComplete { if !isExporting && !exportComplete {
startExport() startExport()
@@ -319,7 +324,9 @@ struct LiveActivityRecordingView: View {
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
exportPath = outputDir.path exportPath = outputDir.path
#if DEBUG
print("📁 Exporting frames to: \(exportPath)") print("📁 Exporting frames to: \(exportPath)")
#endif
let target = targetStreak let target = targetStreak
let outDir = outputDir let outDir = outputDir
@@ -354,7 +361,9 @@ struct LiveActivityRecordingView: View {
await MainActor.run { await MainActor.run {
exportComplete = true exportComplete = true
#if DEBUG
print("✅ Export complete! \(target) frames saved to: \(outPath)") print("✅ Export complete! \(target) frames saved to: \(outPath)")
#endif
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,6 +93,7 @@ struct SwitchableView: View {
theme.currentTheme.secondaryBGColor theme.currentTheme.secondaryBGColor
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
.accessibilityIdentifier(AccessibilityID.SwitchableView.headerToggle)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
.padding(.bottom, 30) .padding(.bottom, 30)
.onTapGesture { .onTapGesture {
@@ -100,6 +101,8 @@ struct SwitchableView: View {
self.headerTypeChanged(viewType) self.headerTypeChanged(viewType)
AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: 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 y: 6
) )
} }
.accessibilityIdentifier(AccessibilityID.TipModal.dismissButton)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.bottom, 24) .padding(.bottom, 24)
.opacity(appeared ? 1 : 0) .opacity(appeared ? 1 : 0)
@@ -245,7 +246,6 @@ extension View {
// MARK: - Tips Preview View (Debug) // MARK: - Tips Preview View (Debug)
#if DEBUG
struct TipsPreviewView: View { struct TipsPreviewView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var selectedTipIndex: Int? @State private var selectedTipIndex: Int?
@@ -308,6 +308,7 @@ struct TipsPreviewView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.accessibilityIdentifier(AccessibilityID.TipModal.tipPreviewButton(index))
} }
} header: { } header: {
Text("Tap to preview") Text("Tap to preview")
@@ -320,11 +321,13 @@ struct TipsPreviewView: View {
ReflectTipsManager.shared.resetAllTips() ReflectTipsManager.shared.resetAllTips()
} }
.foregroundColor(.red) .foregroundColor(.red)
.accessibilityIdentifier(AccessibilityID.TipModal.resetTipsButton)
Toggle("Tips Enabled", isOn: Binding( Toggle("Tips Enabled", isOn: Binding(
get: { ReflectTipsManager.shared.tipsEnabled }, get: { ReflectTipsManager.shared.tipsEnabled },
set: { ReflectTipsManager.shared.tipsEnabled = $0 } set: { ReflectTipsManager.shared.tipsEnabled = $0 }
)) ))
.accessibilityIdentifier(AccessibilityID.TipModal.tipsEnabledToggle)
} header: { } header: {
Text("Settings") Text("Settings")
} }
@@ -346,6 +349,7 @@ struct TipsPreviewView: View {
Button("Done") { Button("Done") {
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.TipModal.doneButton)
} }
} }
.sheet(item: Binding( .sheet(item: Binding(
@@ -379,4 +383,3 @@ private struct TipIndexWrapper: Identifiable {
TipsPreviewView() TipsPreviewView()
} }
} }
#endif

View File

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