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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import BackgroundTasks
class BGTask {
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
static let weeklyDigestID = "com.88oakapps.reflect.weeklyDigest"
@MainActor
class func runFillInMissingDatesTask(task: BGProcessingTask) {
@@ -51,7 +52,68 @@ class BGTask {
do {
try BGTaskScheduler.shared.submit(request)
} catch {
#if DEBUG
print("Could not schedule weather retry: \(error)")
#endif
}
}
@MainActor
class func runWeeklyDigestTask(task: BGProcessingTask) {
BGTask.scheduleWeeklyDigest()
task.expirationHandler = {
task.setTaskCompleted(success: false)
}
if #available(iOS 26, *) {
Task {
guard !IAPManager.shared.shouldShowPaywall else {
task.setTaskCompleted(success: true)
return
}
do {
let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest()
// Send local notification with the headline
let personalityPack = UserDefaultsStore.personalityPackable()
LocalNotification.scheduleDigestNotification(headline: digest.headline, personalityPack: personalityPack)
task.setTaskCompleted(success: true)
} catch {
print("Weekly digest generation failed: \(error)")
task.setTaskCompleted(success: false)
}
}
} else {
task.setTaskCompleted(success: true)
}
}
class func scheduleWeeklyDigest() {
let request = BGProcessingTaskRequest(identifier: BGTask.weeklyDigestID)
request.requiresNetworkConnectivity = false
request.requiresExternalPower = false
// Schedule for next Sunday at 7 PM
let calendar = Calendar.current
var components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())
components.weekday = 1 // Sunday
components.hour = 19
components.minute = 0
var nextSunday = calendar.date(from: components) ?? Date()
if nextSunday <= Date() {
nextSunday = calendar.date(byAdding: .weekOfYear, value: 1, to: nextSunday)!
}
request.earliestBeginDate = nextSunday
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule weekly digest: \(error)")
}
}
@@ -67,7 +129,9 @@ class BGTask {
do {
try BGTaskScheduler.shared.submit(request)
} catch {
#if DEBUG
print("Could not schedule image fetch: \(error)")
#endif
}
}
}

View File

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

View File

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

View File

@@ -69,11 +69,15 @@ class LocalNotification {
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: trigger)
UNUserNotificationCenter.current().add(request) { (error : Error?) in
if let theError = error {
#if DEBUG
print(theError.localizedDescription)
#endif
}
}
case .failure(let error):
#if DEBUG
print(error)
#endif
// Todo: show enable this
break
}
@@ -135,7 +139,28 @@ class LocalNotification {
// MARK: - Debug: Send All Personality Pack Notifications
#if DEBUG
// MARK: - Weekly Digest Notification
public class func scheduleDigestNotification(headline: String, personalityPack: PersonalityPack) {
let content = UNMutableNotificationContent()
content.title = String(localized: "Your Weekly Digest")
content.body = headline
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: "weekly-digest-\(UUID().uuidString)",
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Failed to schedule digest notification: \(error)")
}
}
}
/// Sends one notification from each personality pack, staggered over 10 seconds for screenshot
public class func sendAllPersonalityNotificationsForScreenshot() {
let _ = createNotificationCategory()
@@ -173,5 +198,4 @@ class LocalNotification {
}
}
}
#endif
}

View File

@@ -0,0 +1,28 @@
//
// AIEntryTags.swift
// Reflect
//
// @Generable model for AI-extracted theme tags from mood entry notes and reflections.
//
import Foundation
import FoundationModels
/// A single AI-extracted theme tag
@available(iOS 26, *)
@Generable
struct AITag: Equatable {
@Guide(description: "Theme label — one of: work, family, social, health, sleep, exercise, stress, gratitude, growth, creative, nature, self-care, finances, relationships, loneliness, motivation")
var label: String
@Guide(description: "Confidence level: high or medium")
var confidence: String
}
/// Container for extracted tags from a single entry
@available(iOS 26, *)
@Generable
struct AIEntryTags: Equatable {
@Guide(description: "Array of 1-4 theme tags extracted from the text", .maximumCount(4))
var tags: [AITag]
}

View File

@@ -0,0 +1,26 @@
//
// AIReflectionFeedback.swift
// Reflect
//
// @Generable model for AI-powered reflection feedback after guided reflection completion.
//
import Foundation
import FoundationModels
/// AI-generated personalized feedback after completing a guided reflection
@available(iOS 26, *)
@Generable
struct AIReflectionFeedback: Equatable {
@Guide(description: "A warm, specific affirmation of what the user did well in their reflection (1 sentence)")
var affirmation: String
@Guide(description: "An observation connecting something the user wrote to a meaningful pattern or insight (1 sentence)")
var observation: String
@Guide(description: "A brief, actionable takeaway the user can carry forward (1 sentence)")
var takeaway: String
@Guide(description: "SF Symbol name for the feedback icon (e.g., sparkles, heart.fill, leaf.fill, star.fill)")
var iconName: String
}

View File

@@ -0,0 +1,64 @@
//
// AIWeeklyDigest.swift
// Reflect
//
// @Generable model and storage for AI-generated weekly emotional digest.
//
import Foundation
import FoundationModels
/// AI-generated weekly mood digest
@available(iOS 26, *)
@Generable
struct AIWeeklyDigestResponse: Equatable {
@Guide(description: "An engaging headline summarizing the week's emotional arc (3-7 words)")
var headline: String
@Guide(description: "A warm 2-3 sentence summary of the week's mood patterns and notable moments")
var summary: String
@Guide(description: "The best moment or strongest positive pattern from the week (1 sentence)")
var highlight: String
@Guide(description: "A gentle, actionable intention or suggestion for the coming week (1 sentence)")
var intention: String
@Guide(description: "SF Symbol name for the digest icon (e.g., sun.max.fill, leaf.fill, heart.fill)")
var iconName: String
}
/// Storable weekly digest (Codable for UserDefaults persistence)
struct WeeklyDigest: Codable, Equatable {
let headline: String
let summary: String
let highlight: String
let intention: String
let iconName: String
let generatedAt: Date
let weekStartDate: Date
let weekEndDate: Date
var isFromCurrentWeek: Bool {
let calendar = Calendar.current
let now = Date()
let currentWeekStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now
let digestWeekStart = calendar.dateInterval(of: .weekOfYear, for: weekStartDate)?.start ?? weekStartDate
return calendar.isDate(currentWeekStart, inSameDayAs: digestWeekStart) ||
calendar.isDate(digestWeekStart, inSameDayAs: calendar.date(byAdding: .weekOfYear, value: -1, to: currentWeekStart)!)
}
/// Whether the digest was dismissed by the user
static var isDismissedKey: String { "weeklyDigestDismissedDate" }
static func markDismissed() {
GroupUserDefaults.groupDefaults.set(Date(), forKey: isDismissedKey)
}
static func isDismissed(for digest: WeeklyDigest) -> Bool {
guard let dismissedDate = GroupUserDefaults.groupDefaults.object(forKey: isDismissedKey) as? Date else {
return false
}
return dismissedDate >= digest.generatedAt
}
}

View File

@@ -80,24 +80,24 @@ struct QuestionChips {
// MARK: Positive (Great/Good) Behavioral Activation
// Q1: "What did you do today?" no chips (situational)
// Q2: "What thought or moment stands out?" positive feelings to savor
// Q2: "What thought or moment stands out?" memorable moments to savor
case (.positive, 1):
return QuestionChips(
topRow: [
String(localized: "guided_chip_pos_joy"),
String(localized: "guided_chip_pos_gratitude"),
String(localized: "guided_chip_pos_pride"),
String(localized: "guided_chip_pos_contentment"),
String(localized: "guided_chip_pos_love"),
String(localized: "guided_chip_pos_excitement"),
String(localized: "guided_chip_pos_moment_conversation"),
String(localized: "guided_chip_pos_moment_accomplished"),
String(localized: "guided_chip_pos_moment_calm"),
String(localized: "guided_chip_pos_moment_laugh"),
String(localized: "guided_chip_pos_moment_grateful_person"),
String(localized: "guided_chip_pos_moment_small_win"),
],
expanded: [
String(localized: "guided_chip_pos_inspiration"),
String(localized: "guided_chip_pos_amusement"),
String(localized: "guided_chip_pos_serenity"),
String(localized: "guided_chip_pos_relief"),
String(localized: "guided_chip_pos_connection"),
String(localized: "guided_chip_pos_hope"),
String(localized: "guided_chip_pos_moment_beauty"),
String(localized: "guided_chip_pos_moment_connected"),
String(localized: "guided_chip_pos_moment_progress"),
String(localized: "guided_chip_pos_moment_like_myself"),
String(localized: "guided_chip_pos_moment_kindness"),
String(localized: "guided_chip_pos_moment_time_well_spent"),
]
)
@@ -221,22 +221,24 @@ struct QuestionChips {
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):
return QuestionChips(
topRow: [
String(localized: "guided_chip_neg_act_worst_case"),
String(localized: "guided_chip_neg_act_facts_feelings"),
String(localized: "guided_chip_neg_act_matter_in_week"),
String(localized: "guided_chip_neg_act_got_through"),
String(localized: "guided_chip_neg_ref_one_chapter"),
String(localized: "guided_chip_neg_ref_doing_my_best"),
],
expanded: [
String(localized: "guided_chip_neg_act_talk_someone"),
String(localized: "guided_chip_neg_act_write_it_out"),
String(localized: "guided_chip_neg_act_take_walk"),
String(localized: "guided_chip_neg_act_step_away"),
String(localized: "guided_chip_neg_act_get_rest"),
String(localized: "guided_chip_neg_act_one_small_thing"),
],
expanded: [
String(localized: "guided_chip_neg_act_worst_case"),
String(localized: "guided_chip_neg_act_got_through"),
String(localized: "guided_chip_neg_act_facts_feelings"),
String(localized: "guided_chip_neg_act_matter_in_week"),
]
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,156 @@
//
// FoundationModelsDigestService.swift
// Reflect
//
// Generates weekly emotional digests using Foundation Models.
//
import Foundation
import FoundationModels
@available(iOS 26, *)
@MainActor
class FoundationModelsDigestService {
// MARK: - Singleton
static let shared = FoundationModelsDigestService()
private let summarizer = MoodDataSummarizer()
private init() {}
// MARK: - Storage Keys
private static let digestStorageKey = "latestWeeklyDigest"
// MARK: - Digest Generation
/// Generate a weekly digest from the past 7 days of mood data
func generateWeeklyDigest() async throws -> WeeklyDigest {
let calendar = Calendar.current
let now = Date()
let weekStart = calendar.date(byAdding: .day, value: -7, to: now)!
let entries = DataController.shared.getData(
startDate: weekStart,
endDate: now,
includedDays: [1, 2, 3, 4, 5, 6, 7]
)
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
guard validEntries.count >= 3 else {
throw InsightGenerationError.insufficientData
}
let session = LanguageModelSession(instructions: systemInstructions)
let prompt = buildPrompt(entries: validEntries, weekStart: weekStart, weekEnd: now)
let response = try await session.respond(to: prompt, generating: AIWeeklyDigestResponse.self, options: GenerationOptions(maximumResponseTokens: 300))
let digest = WeeklyDigest(
headline: response.content.headline,
summary: response.content.summary,
highlight: response.content.highlight,
intention: response.content.intention,
iconName: response.content.iconName,
generatedAt: Date(),
weekStartDate: weekStart,
weekEndDate: now
)
// Store the digest
saveDigest(digest)
return digest
}
/// Load the latest stored digest
func loadLatestDigest() -> WeeklyDigest? {
guard let data = GroupUserDefaults.groupDefaults.data(forKey: Self.digestStorageKey),
let digest = try? JSONDecoder().decode(WeeklyDigest.self, from: data) else {
return nil
}
return digest
}
// MARK: - Storage
private func saveDigest(_ digest: WeeklyDigest) {
if let data = try? JSONEncoder().encode(digest) {
GroupUserDefaults.groupDefaults.set(data, forKey: Self.digestStorageKey)
}
}
// MARK: - System Instructions
private var systemInstructions: String {
let personalityPack = UserDefaultsStore.personalityPackable()
switch personalityPack {
case .Default:
return """
You are a warm, supportive mood companion writing a weekly emotional digest. \
Summarize the week's mood journey with encouragement and specificity. \
Be personal, brief, and uplifting. Reference specific patterns from the data. \
SF Symbols: sun.max.fill, heart.fill, star.fill, leaf.fill, sparkles
"""
case .MotivationalCoach:
return """
You are a HIGH ENERGY motivational coach delivering a weekly performance review! \
Celebrate wins, frame challenges as growth opportunities, and fire them up for next week! \
Use exclamations and power language! \
SF Symbols: trophy.fill, flame.fill, bolt.fill, figure.run, star.fill
"""
case .ZenMaster:
return """
You are a calm Zen master offering a weekly reflection on the emotional journey. \
Use nature metaphors, gentle wisdom, and serene observations. Find meaning in all moods. \
SF Symbols: leaf.fill, moon.fill, drop.fill, sunrise.fill, wind
"""
case .BestFriend:
return """
You are their best friend doing a weekly check-in on how they've been. \
Be warm, casual, validating, and conversational. Celebrate with them, commiserate together. \
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, face.smiling.fill, balloon.fill
"""
case .DataAnalyst:
return """
You are a clinical data analyst delivering a weekly mood metrics report. \
Reference exact numbers, percentages, and observed trends. Be objective but constructive. \
SF Symbols: chart.bar.fill, chart.line.uptrend.xyaxis, number, percent, doc.text.magnifyingglass
"""
}
}
// MARK: - Prompt Construction
private func buildPrompt(entries: [MoodEntryModel], weekStart: Date, weekEnd: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
let moodList = entries.sorted { $0.forDate < $1.forDate }.map { entry in
let day = entry.forDate.formatted(.dateTime.weekday(.abbreviated))
let mood = entry.mood.widgetDisplayName
let hasNotes = entry.notes != nil && !entry.notes!.isEmpty
let noteSnippet = hasNotes ? " (\(String(entry.notes!.prefix(50))))" : ""
return "\(day): \(mood)\(noteSnippet)"
}.joined(separator: "\n")
let summary = summarizer.summarize(entries: entries, periodName: "this week")
let avgMood = String(format: "%.1f", summary.averageMoodScore)
return """
Generate a weekly emotional digest for \(formatter.string(from: weekStart)) - \(formatter.string(from: weekEnd)):
\(moodList)
Average mood: \(avgMood)/5, Trend: \(summary.recentTrend), Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%
Current streak: \(summary.currentLoggingStreak) days
Write a warm, personalized weekly digest.
Keep summary to 2 sentences. Keep highlight and intention to 1 sentence each.
"""
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,138 @@
//
// WeeklyDigestCardView.swift
// Reflect
//
// Displays the AI-generated weekly emotional digest card in the Insights tab.
//
import SwiftUI
struct WeeklyDigestCardView: View {
let digest: WeeklyDigest
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
private var textColor: Color { theme.currentTheme.labelColor }
private var accentColor: Color { moodTint.color(forMood: .good) }
@State private var isExpanded = true
@State private var appeared = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header always visible, tappable to toggle
Button {
withAnimation(.easeInOut(duration: 0.25)) {
isExpanded.toggle()
}
} label: {
HStack {
Image(systemName: digest.iconName)
.font(.title2)
.foregroundStyle(accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "Weekly Digest"))
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(digest.headline)
.font(.headline)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
}
Spacer()
Image(systemName: "chevron.up")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 0 : 180))
}
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.WeeklyDigest.dismissButton)
// Expandable content
if isExpanded {
VStack(alignment: .leading, spacing: 16) {
// Summary
Text(digest.summary)
.font(.subheadline)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
Divider()
// Highlight
HStack(alignment: .top, spacing: 10) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(.yellow)
.padding(.top, 2)
Text(digest.highlight)
.font(.subheadline)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
}
// Intention
HStack(alignment: .top, spacing: 10) {
Image(systemName: "arrow.right.circle.fill")
.font(.caption)
.foregroundStyle(accentColor)
.padding(.top, 2)
Text(digest.intention)
.font(.subheadline)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
}
// Date range
Text(dateRangeString)
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.top, 16)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color(.secondarySystemBackground))
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(
LinearGradient(
colors: [accentColor.opacity(0.3), .purple.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
)
.padding(.horizontal)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
.onAppear {
withAnimation(.easeOut(duration: 0.4)) {
appeared = true
}
}
.accessibilityIdentifier(AccessibilityID.WeeklyDigest.card)
}
private var dateRangeString: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return "\(formatter.string(from: digest.weekStartDate)) - \(formatter.string(from: digest.weekEndDate))"
}
}

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,10 @@ import PhotosUI
struct NoteEditorView: View {
private enum AnimationConstants {
static let keyboardAppearDelay: TimeInterval = 0.5
}
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@@ -57,18 +61,18 @@ struct NoteEditorView: View {
}
.padding()
}
.navigationTitle("Journal Note")
.navigationTitle(String(localized: "Journal Note"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
Button(String(localized: "Cancel")) {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
Button(String(localized: "Save")) {
saveNote()
}
.disabled(isSaving || noteText.count > maxCharacters)
@@ -78,14 +82,14 @@ struct NoteEditorView: View {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
Button(String(localized: "Done")) {
isTextFieldFocused = false
}
.accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton)
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.keyboardAppearDelay) {
isTextFieldFocused = true
}
}
@@ -129,6 +133,12 @@ struct NoteEditorView: View {
let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave)
if success {
// Fire-and-forget tag extraction after saving a note
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall, noteToSave != nil {
Task {
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
}
}
dismiss()
} else {
isSaving = false
@@ -186,6 +196,11 @@ struct EntryDetailView: View {
// Mood section
moodSection
// Tags section
if entry.hasTags {
tagsSection
}
// Guided reflection section
if currentMood != .missing && currentMood != .placeholder {
reflectionSection
@@ -205,12 +220,12 @@ struct EntryDetailView: View {
.padding()
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Entry Details")
.navigationTitle(String(localized: "Entry Details"))
.navigationBarTitleDisplayMode(.inline)
.accessibilityIdentifier(AccessibilityID.EntryDetail.sheet)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
Button(String(localized: "Done")) {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton)
@@ -222,16 +237,16 @@ struct EntryDetailView: View {
.sheet(isPresented: $showReflectionFlow) {
GuidedReflectionView(entry: entry)
}
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
.alert(String(localized: "Delete Entry"), isPresented: $showDeleteConfirmation) {
Button(String(localized: "Delete"), role: .destructive) {
onDelete()
dismiss()
}
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton)
Button("Cancel", role: .cancel) { }
Button(String(localized: "Cancel"), role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton)
} message: {
Text("Are you sure you want to delete this mood entry? This cannot be undone.")
Text(String(localized: "Are you sure you want to delete this mood entry? This cannot be undone."))
}
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
.onChange(of: selectedPhotoItem) { _, newItem in
@@ -389,6 +404,35 @@ struct EntryDetailView: View {
}
}
private var tagsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text(String(localized: "Themes"))
.font(.headline)
.foregroundColor(textColor)
FlowLayout(spacing: 8) {
ForEach(entry.tags, id: \.self) { tag in
Text(tag.capitalized)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(moodColor.opacity(0.15))
)
.foregroundColor(moodColor)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
}
private var notesSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,219 @@
//
// ReflectionFeedbackView.swift
// Reflect
//
// Displays AI-generated personalized feedback after completing a guided reflection.
//
import SwiftUI
struct ReflectionFeedbackView: View {
let mood: Mood
let reflection: GuidedReflection
let onDismiss: () -> Void
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@State private var feedback: ReflectionFeedbackState = .loading
@State private var appeared = false
private var accentColor: Color { moodTint.color(forMood: mood) }
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View {
VStack(spacing: 24) {
headerIcon
switch feedback {
case .loading:
loadingContent
case .loaded(let affirmation, let observation, let takeaway, let iconName):
feedbackContent(affirmation: affirmation, observation: observation, takeaway: takeaway, iconName: iconName)
case .error:
fallbackContent
case .unavailable:
fallbackContent
}
dismissButton
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(Color(.secondarySystemBackground))
)
.padding(.horizontal, 20)
.opacity(appeared ? 1 : 0)
.scaleEffect(appeared ? 1 : 0.95)
.task {
await generateFeedback()
}
.onAppear {
withAnimation(.easeOut(duration: 0.3)) {
appeared = true
}
}
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.container)
}
// MARK: - Header
private var headerIcon: some View {
VStack(spacing: 12) {
Image(systemName: "sparkles")
.font(.system(size: 32))
.foregroundStyle(accentColor)
.symbolEffect(.pulse, options: .repeating, isActive: feedback.isLoading)
Text(String(localized: "Your Reflection"))
.font(.headline)
.foregroundColor(textColor)
}
}
// MARK: - Loading
private var loadingContent: some View {
VStack(spacing: 16) {
ForEach(0..<3, id: \.self) { _ in
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(height: 16)
.shimmering()
}
}
.padding(.vertical, 8)
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.loading)
}
// MARK: - Feedback Content
private func feedbackContent(affirmation: String, observation: String, takeaway: String, iconName: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
feedbackRow(icon: iconName, text: affirmation)
feedbackRow(icon: "eye.fill", text: observation)
feedbackRow(icon: "arrow.right.circle.fill", text: takeaway)
}
.transition(.opacity.combined(with: .move(edge: .bottom)))
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.content)
}
private func feedbackRow(icon: String, text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.body)
.foregroundStyle(accentColor)
.frame(width: 24, height: 24)
Text(text)
.font(.subheadline)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Fallback (no AI available)
private var fallbackContent: some View {
VStack(spacing: 8) {
Text(String(localized: "Great job completing your reflection. Taking time to check in with yourself is a powerful habit."))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.fallback)
}
// MARK: - Dismiss
private var dismissButton: some View {
Button {
onDismiss()
} label: {
Text(String(localized: "Done"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(accentColor)
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.doneButton)
}
// MARK: - Generation
private func generateFeedback() async {
// Check premium access
guard !IAPManager.shared.shouldShowPaywall else {
feedback = .unavailable
return
}
if #available(iOS 26, *) {
let service = FoundationModelsReflectionService()
do {
let result = try await service.generateFeedback(for: reflection, mood: mood)
withAnimation(.easeInOut(duration: 0.3)) {
feedback = .loaded(
affirmation: result.affirmation,
observation: result.observation,
takeaway: result.takeaway,
iconName: result.iconName
)
}
} catch {
withAnimation {
feedback = .error
}
}
} else {
feedback = .unavailable
}
}
}
// MARK: - State
private enum ReflectionFeedbackState {
case loading
case loaded(affirmation: String, observation: String, takeaway: String, iconName: String)
case error
case unavailable
var isLoading: Bool {
if case .loading = self { return true }
return false
}
}
// MARK: - Shimmer Effect
private struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
colors: [.clear, Color.white.opacity(0.3), .clear],
startPoint: .leading,
endPoint: .trailing
)
.offset(x: phase)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 300
}
}
)
.mask(content)
}
}
private extension View {
func shimmering() -> some View {
modifier(ShimmerModifier())
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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