feat(richness+streak): saturation pass + Duolingo streak + email/accountability scaffold#18
Open
Outtsett wants to merge 12 commits intodesign/ux-upgrade-screensfrom
Open
feat(richness+streak): saturation pass + Duolingo streak + email/accountability scaffold#18Outtsett wants to merge 12 commits intodesign/ux-upgrade-screensfrom
Outtsett wants to merge 12 commits intodesign/ux-upgrade-screensfrom
Conversation
… research grounding
…mail-hub design doc
There was a problem hiding this comment.
Pull request overview
This PR bundles three feature tracks into the HabitDeveloper Flutter app: a saturated “visual richness” theming pass (tokens + scaffold background + filled inputs + SectionCard-driven forms), a new Duolingo-style whole-app daily streak system (with freezes + milestone celebration UI), and a local-only engagement-preferences scaffold (email reminders, accountability partner, commitment device) plus supporting documentation updates.
Changes:
- Introduces
DailyStreak/StreakServicewithStreakFireindicator + milestone overlay, and wires it into Home + Settings. - Adds
EngagementPreferencesServiceand new Settings sections for email/accountability/commitment (local persistence only). - Updates design tokens and theming (warm cream scaffold background, filled inputs, chip/segmented styling) and restructures onboarding/add-habit UI accordingly.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
lib/design/tokens.dart |
Saturation-pass token ramps (indigo/emerald/orange/rose) and updated semantic palette mappings. |
lib/models/app_theme.dart |
Warm cream / navy scaffold backgrounds; filled input defaults; chip + segmented theming updates. |
lib/widgets/section_card.dart |
New reusable “SectionCard” UI container used for richer forms/sections. |
lib/screens/add_habit_screen.dart |
Rebuilds AddHabit form into multiple SectionCard sections with richer styling/copy. |
test/add_habit_screen_test.dart |
Updates widget test field indexing to match reorganized AddHabit form. |
lib/models/daily_streak.dart |
Adds Hive-backed DailyStreak model and StreakTier enum. |
lib/models/daily_streak.g.dart |
Adds adapters for DailyStreak/StreakTier (currently manually maintained). |
lib/services/streak_service.dart |
Implements whole-app daily streak logic (completion-driven, freezes, milestones, listeners). |
lib/widgets/streak_fire.dart |
New streak indicator widget + bottom-sheet detail UI. |
lib/widgets/streak_milestone.dart |
New full-screen overlay celebration with confetti burst + milestone card. |
lib/screens/home_screen.dart |
Adds TodaySummaryHero with StreakFire; updates completion flow to tick streak + show milestone overlay. |
lib/services/engagement_preferences_service.dart |
New Hive-settings-backed ChangeNotifier for engagement preferences (email/partner/commitment). |
lib/screens/settings_screen.dart |
Adds new sections: daily streak snapshot, email reminders, accountability partner, commitment device. |
test/graceful_degradation_test.dart |
Extends “graceful degradation” test wiring to include streak + engagement services. |
docs/EMAIL_SYSTEM.md |
Adds backend design doc for future habit-email-hub service. |
lib/main.dart |
Registers new Hive adapters; opens streak box; adds providers for streak + engagement services. |
lib/screens/onboarding_screen.dart |
Expands onboarding pages with richer content and replaces dot indicators with labeled progress. |
CLAUDE.md |
Documents hot-reload stdin workflow and port convention for local dev. |
Comment on lines
+27
to
+31
| StreakService({required Box<DailyStreak> box, this.now}) : _box = box { | ||
| _state = _box.get(_singletonKey) ?? DailyStreak(); | ||
| if (!_box.containsKey(_singletonKey)) { | ||
| _box.put(_singletonKey, _state); | ||
| } |
Comment on lines
+44
to
+46
| /// Current snapshot. Widgets should `context.watch<StreakService>()` and | ||
| /// read this. Don't mutate directly. | ||
| DailyStreak get state => _state; |
Comment on lines
+82
to
+84
| final last = DateTime.parse(_state.lastCompletionDayKey!); | ||
| final gap = today.difference(last).inDays; | ||
| if (gap == 1) { |
Comment on lines
+63
to
+66
| /// Called every time a habit is marked complete. Idempotent within | ||
| /// the same calendar day. Returns the milestone integer if a new one | ||
| /// was just crossed, else null. | ||
| Future<int?> onCompletion() async { |
Comment on lines
+1
to
+10
| // GENERATED CODE — DO NOT MODIFY BY HAND. | ||
| // | ||
| // Hand-authored on 2026-05-04 to avoid running build_runner on Windows | ||
| // without Developer Mode (build_runner -> .dart_tool symlink path that | ||
| // requires symlink privileges). Mirror the shape that | ||
| // `flutter pub run build_runner build` would emit for HiveType(13) | ||
| // + HiveType(14). | ||
| // | ||
| // Re-run `flutter pub run build_runner build --delete-conflicting-outputs` | ||
| // later if any @HiveField changes; that will overwrite this file. |
Comment on lines
+1134
to
+1142
| child: TextField( | ||
| controller: _name, | ||
| decoration: const InputDecoration( | ||
| labelText: "Partner's name", | ||
| hintText: 'Sam, Mom, Coach Ryan…', | ||
| ), | ||
| onSubmitted: prefs.setPartnerName, | ||
| onEditingComplete: () => prefs.setPartnerName(_name.text), | ||
| ), |
Comment on lines
+1249
to
+1250
| onEditingComplete: () => | ||
| prefs.setCommitmentStatement(_statement.text), |
Comment on lines
+1206
to
+1287
| return Column( | ||
| crossAxisAlignment: CrossAxisAlignment.stretch, | ||
| children: [ | ||
| _SectionHeader( | ||
| 'Commitment device', | ||
| icon: Icons.gavel_rounded, | ||
| accent: tokens.warning, | ||
| ), | ||
| Padding( | ||
| padding: const EdgeInsets.fromLTRB( | ||
| Spacing.s4, | ||
| 0, | ||
| Spacing.s4, | ||
| Spacing.s2, | ||
| ), | ||
| child: Text( | ||
| 'Write a personal contract. We surface it at the moment ' | ||
| 'your streak is at risk — Ariely (2010) self-control ' | ||
| 'contract pattern.', | ||
| style: textTheme.bodySmall?.copyWith( | ||
| color: tokens.onSurfaceMuted, | ||
| ), | ||
| ), | ||
| ), | ||
| Padding( | ||
| padding: const EdgeInsets.fromLTRB( | ||
| Spacing.s4, | ||
| 0, | ||
| Spacing.s4, | ||
| Spacing.s3, | ||
| ), | ||
| child: TextField( | ||
| controller: _statement, | ||
| maxLines: 3, | ||
| minLines: 2, | ||
| decoration: InputDecoration( | ||
| labelText: | ||
| 'If I miss ' | ||
| '${prefs.commitmentMissThreshold} days, I will…', | ||
| hintText: | ||
| 'donate \$25 to Wikipedia / call Sam / no coffee for a week', | ||
| ), | ||
| onSubmitted: prefs.setCommitmentStatement, | ||
| onEditingComplete: () => | ||
| prefs.setCommitmentStatement(_statement.text), | ||
| ), | ||
| ), | ||
| Padding( | ||
| padding: const EdgeInsets.fromLTRB( | ||
| Spacing.s4, | ||
| 0, | ||
| Spacing.s4, | ||
| Spacing.s2, | ||
| ), | ||
| child: Row( | ||
| children: [ | ||
| Text('Trigger after', style: textTheme.bodyMedium), | ||
| Expanded( | ||
| child: Slider( | ||
| min: 1, | ||
| max: 7, | ||
| divisions: 6, | ||
| value: prefs.commitmentMissThreshold.toDouble(), | ||
| label: '${prefs.commitmentMissThreshold}', | ||
| activeColor: tokens.warning, | ||
| onChanged: (v) => | ||
| prefs.setCommitmentMissThreshold(v.round()), | ||
| ), | ||
| ), | ||
| Text( | ||
| '${prefs.commitmentMissThreshold} ' | ||
| '${prefs.commitmentMissThreshold == 1 ? "day" : "days"}', | ||
| style: AppTypography.mono( | ||
| color: tokens.warning, | ||
| fontSize: 14, | ||
| fontWeight: FontWeight.w800, | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ], |
Comment on lines
+228
to
+234
| Row( | ||
| children: [ | ||
| Icon( | ||
| Icons.local_fire_department_rounded, | ||
| size: 36, | ||
| color: tokens.warning, | ||
| ), |
Comment on lines
+39
to
+41
| /// Freezes available — earned 1 per 7-day milestone (7/14/21/...). When | ||
| /// the streak would otherwise break (i.e. yesterday had no completion), | ||
| /// 1 freeze is automatically consumed and the streak survives. |
PageView gives each page a tall fixed-height slot, but _PageScaffold's SingleChildScrollView + top-aligned Column took intrinsic height and left a ~70% cream void below the action buttons on desktop web. Wrap with LayoutBuilder + ConstrainedBox(minHeight: maxHeight) + IntrinsicHeight so the column stretches to fill the slot, then center content with mainAxisAlignment.center. Cap content at 560px wide so the layout doesn't sprawl across desktop browsers. Falls back to SingleChildScrollView when content overflows on small screens. Affects all 4 onboarding pages — Welcome, Identity, Reminders, Health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #19 (ci/automation-pipeline) added 4 new workflows + 1 labeler config + 2 fleshed-out issue templates. Document the full GitHub Actions surface (8 workflows × what they do × triggers) and the current open-PR table so future sessions can pick up state without re-discovering the automation footprint. Includes the "branch protection action item" Tyler must do manually in repo settings (4 required status checks) before dependabot-auto- merge.yml is actually safe — the workflow only enables auto-merge, the protection rules enforce the gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on PR #17 — three orthogonal feature areas that ship together.
Visual richness
indigo.600 / emerald.700 / orange.700 / rose.700(Cyr-Head-Larios 2010 wellness-app engagement; Palmer-Schloss 2010 positive-outcome cluster; Hill 2008 dominance pair). All 32 WCAG AA contrast tests still pass.#F4EFE6— replaces the "still looks white" slate.50 background. Light theme cards lift visibly off the cream; dark theme uses a deeper navy variant. Premium themes preserve their brand neutrals.InputDecorationTheme,ChipThemeData,SegmentedButtonThemeData. No more bare-outlined-rectangle look.SectionCardwidget — soft-rounded card with colored leading-icon header used across forms.08:00(22pt) + skip-tolerance2(36pt).Duolingo-style daily streak
DailyStreakmodel (HiveType 13) +StreakTierenum (HiveType 14) — Bronze/Silver/Gold/Platinum/Diamond tiers map cleanly fromcurrentStreaklength.StreakService—onCompletion()is same-day-idempotent, increments on 1-day gap, consumes freezes on 2+day gap (1 freeze per missed day), awards 1 freeze per milestone (7/14/30/60/100/365), fires milestone listeners.StreakFirewidget — pulsing flame with mono digit count + tier label + freeze-count chip; tap → bottom sheet with longest / freezes / days-until-next-milestone + "About freezes" copy. Compact mode for AppBar use.StreakMilestoneOverlay— full-screen 3.8s celebration on milestone hit: black 0.45 scrim + 60-particle radial confetti burst + tier-up announcement + "+1 freeze earned" chip + heavy haptic.Email + accountability + commitment device (local-only today)
EngagementPreferencesService— Hive-backed ChangeNotifier in the existingapp_settingsbox (no new HiveType). Stores email opt-in flag + address + 4 sub-toggles (daily / atRisk / milestone / weekly), partner enable + name + email, commitment-device statement + miss-threshold slider (1-7 days).docs/EMAIL_SYSTEM.md— 250-line design doc for thehabit-email-hubExpress service (Resend + react-email + Drizzle/SQLite, modeled exactly on the existingpaypal-hub). Lists every email type, API surface, scheduling, audit, deployment plan, and privacy implications. Awaiting Tyler's sign-off before standing it up.Hot-reload-no-relaunch rule
~/.claude/CLAUDE.mdand project CLAUDE.md.mkfifo /tmp/flutter_stdin; tail -f /tmp/flutter_stdin | flutter run -d chrome --web-port=8085 &. Thenecho r > /tmp/flutter_stdinfor reload,echo Rfor restart.--web-port=8085(not 8080 — Apachehttpdis already bound on Tyler's box).Test plan
flutter analyze --fatal-infos— cleanflutter test— 62 pass / 2 skip (preserved)flutter build apk --debug— builtBackend follow-up
Email + partner notifications need
habit-email-hub. Design doc is ready; waiting on go-ahead before scaffolding the Express service in a separate repo.🤖 Generated with Claude Code