From 97af520fdd4d3840fe34f6b7ea4b09ecdf656bc6 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 21:12:46 +0100 Subject: [PATCH] Add log sound cue and progress card motion - lightImpact + 160 ms confirm cue on a validated log (audioplayers, no audio focus); device-local opt-out switch in Settings, on by default - post-log card: confirmation line (slot + the meal's kcal), entrance and kcal/streak count-up on motion tokens transcribed into ui/theme.dart; durations collapse to zero under disableAnimations - test/flutter_test_config.dart mutes the cue suite-wide Todo: 8e66c1b9 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWVHMLzeojNP4AaPoEj --- app/assets/sounds/log_confirm.ogg | Bin 0 -> 4849 bytes app/lib/screens/log_meal_progress.dart | 38 ++++- app/lib/screens/log_meal_screen.dart | 8 +- app/lib/screens/settings_screen.dart | 11 +- app/lib/services/app_settings_service.dart | 18 +++ app/lib/services/confirm_cue_player.dart | 22 +++ app/lib/services/log_feedback.dart | 19 +++ app/lib/ui/theme.dart | 36 +++++ app/lib/widgets/log_sound_tile.dart | 36 +++++ app/lib/widgets/progress_motion.dart | 66 ++++++++ app/lib/widgets/today_progress_card.dart | 150 +++++++++++------ app/pubspec.lock | 56 +++++++ app/pubspec.yaml | 5 +- app/test/flutter_test_config.dart | 10 ++ app/test/screens/log_meal_haptic_test.dart | 26 ++- app/test/screens/log_meal_progress_test.dart | 40 +++++ .../services/app_settings_sound_test.dart | 65 ++++++++ app/test/ui/theme_motion_test.dart | 47 ++++++ app/test/widgets/log_sound_tile_test.dart | 72 +++++++++ .../today_progress_card_motion_test.dart | 153 ++++++++++++++++++ 20 files changed, 805 insertions(+), 73 deletions(-) create mode 100644 app/assets/sounds/log_confirm.ogg create mode 100644 app/lib/services/confirm_cue_player.dart create mode 100644 app/lib/services/log_feedback.dart create mode 100644 app/lib/widgets/log_sound_tile.dart create mode 100644 app/lib/widgets/progress_motion.dart create mode 100644 app/test/flutter_test_config.dart create mode 100644 app/test/screens/log_meal_progress_test.dart create mode 100644 app/test/services/app_settings_sound_test.dart create mode 100644 app/test/ui/theme_motion_test.dart create mode 100644 app/test/widgets/log_sound_tile_test.dart create mode 100644 app/test/widgets/today_progress_card_motion_test.dart diff --git a/app/assets/sounds/log_confirm.ogg b/app/assets/sounds/log_confirm.ogg new file mode 100644 index 0000000000000000000000000000000000000000..0b519f454d01684069e3451b88c818182935c7d7 GIT binary patch literal 4849 zcmb7IeLU0a`@iRzmNSu%v`I0CGSP{X&c5@wMKMNbrG+hxv%SbU-$Ld zx-DBmffo3D5@nHvnF(L#gkVQR|SIe_zNS#iz_nR2L71a7~A8RNnTLK_#(q5#>Z-Ap>#F( zB_!`~6fgjQXB%j7lh<890{{a+S?zbiwDzl0f28fN_BTt*MyN-%{@r`B8zJ@LYL!*m zZOG$b-Qwl~paXb#gVLm7(zB~mHkM;1Ay$rU2$h-RBCyQ#DjtZMz#YC%dge85V=y~X z7eLDp4RCxjY5*?tONSSi#2&;~mc(}8tE@Slgf?u3vFDSD!@(p&d_^$DQBonGQk+#$ zoEKO1A8=m&YZ}YPkf4$;oq8Ww_%4tcr)DCWHTpE$!U7&N3m_Mo-LALp>ap&6ZGFdN zambv-J+96Qs(a{iFIt$dGK>~Mqtkb>l__kcklh!qCc@c~4eY^xHucuAC{VV@qi7Mn z$QEDpXl0QOKdV*?#33drb2#{DW&Y!U{KfG_n^T2ba!RX@mOhc3Fj5ZzC^!VUhIbzv z-<`iDwMgBUR>hZA{-=$&wcQaAp)K2XT5FnrlAXTV!tDV1lt{H}OLFq(xV3Rs`6v6d zB?sOTgx!*EnDxa8(CzRdnmYN0u4+5H>W-=V9fez$X;6q;km5gRp=SHl@d7u3JHA<% zz%y805TiB=5302&H}FPlR)Pbv%O7J)yG!46c^H?DmPyQxDX!&&9kXwglpN#8J;p_e za?3{|jvU`{w59B5cPU3sMwf~;RO*~#`gM|C$g><58Mj)-z;W@trTUWo^>A)vcj;Jl zqZy=f1jp>9jbR#nYNZ^xljw z&!YAC0c?7{UaT3e5_i}p`H=D9GD17P!X_`@k0QwkrdryIlMkqOjs<{^`8tYmSr)O` zxGGVSkz;j5(Lu)!hYOvqx1Er#_J-VZ;@l04H=1yPsK$%iB0Fiy0~WN7HN_?gSukQo~UYn@eaDWvnF`Y8?C-IsQA%+6t|2 z722&iZx__-)b*F!x;Jh;roJ#-Lg&(Z=5iJ0%+M8dg*#J0V=7aa{SD0MOX-mf=|fXr zwLb3G$bqo2^~iq|@K5CAKU|r=h+JeBS+qor9LdoWPbDY+7jnwB9;_-kSe1QnJUibZ zM;M<|TGMzkvE$6dm4Dm*Mh*lFY`#=G-+v%SMzHpV;5qLU^hUEsuO65kl6~iW?EnB| z1?V+MDzOWpIxDH}N~$w0%!~H#E+qmjXxL(Prk(9MZIyPb7^B&dCcp;;Fa|hr0W|%5g2OfOH=xU(1IeQ?4B@Ybg!G@E_`$k38RI$v-`tngOh;@VNjov z9YW(qH?U)Nvj?ZV6c^zm`#~~2dKbHYiW%LP=dV^30abB>B9!bI!Bbfd3 zwmn#}cW^3mi~6m4Xzo2>bOntI*Bw+Lsv@LJ$muU;4@J=XC*4Dm={+=NWHK!>oY_A` zkDTO&(9)xenTT|XJv1+?UzI+ROs6B7!y_lt2m6XgRos#3Kt%)GAXmXnADZNDIm)F^ z`}Vy{rwP*;Qs&0#%pu9g*4Mc;H4`<@jhY>AUq3hNw>S5jmI=6n)9eRZ?l&EJ{peF` zA#3nDVWNR^zv+xq^PA;+nd|pd#t&9be7N%TO4G85D}?(^XU%BJK3epOEM;-#BlEqF z?xl~c^!t0En0x6%yZ6TIKGD~ZH!_*tkvQSE@TXt@>b#Qo{9|jFFdvAGNfZ+X#&x_Ru9Ivit}v&Nonz6Yu@XV22fkb& zB;lPi=S0mC-!!Mh*Ga=!3C=1Eg;g%F@Wwk=suBY0lvV^i9A_?~PpGdjV~mOfXivuld;!IgP%MZ=OKyf!4@xV-7_9nHbB4TJV7|t&9tzlmQ42<= z2ePcy2-a389B&{lm=m87MVCBN2I!dU05vSxI8wFp^VjZ>x31rX}!mGD3+3D$$KHB`fv z@6ckw*Tc1n4tHa3mT;(!t)h$qN=pd`L+KD@1XCzSAw|XuiYS!2Oe%#UkwVxSA+SyC z7Ob}}Ly`{B5%8|vfnSyY7+-S-MXAK7iFv1C=DY@JSrbuDb z2P1zCwM8v$Tn391jFFrOc zL$|#O)CMwBYrr!~T)$wXVyn|R^;_uy5Q8S^;WE)ASQQFL0rR>pS?-r7z9 zSXgOdZ$`1|;9(gnfgPl>DC)=mt5ct<|L<-tcoH!JhG#b2cxGVl%h;cpRjUK8!`hkw zW7PAhUBz9OQ42nmN){LU5yr8CA}XbhCMO4o#j2=Z>XX(&FG$U!b73$>B{;WQF#d#5 z8pR?&%0Vo)VAK*+QQl;U)Pj-g-WNrML*Q;2r;4b*5L!2|UNjz2#nwv4V0GdpHD}bh zkKgCKLI%sFu=)1S%r-xT%BxC$!e^9(r65T~Atbme?R3qm35swqdY82~$m;gPsW=XhLLBtLJ-hTT9N z_^?#Q%A*qTeu$y7+OapWSw{F4kra*3u&xZm^999nLRR)+^XOS~wP7`jK}sjB{aOw# z1t_3zU`PeTnEmD6d;`oWne6iAi|}iQ6qgm?#z0R_~Kdf{7BvoJinFbT)HvqKhVN80g{eN!L56Ese zAd_JQ1>A#r%JTgE;6X&?AhKJ;liHD&AXO&hal_ILIFDf2zxm* zTXqr9v;s_dhD05(C-TI{hg-H@9)I$#{0Wt$i!-}-fq3ZH;HB+DP3dXycvcqvb&3V@urzXE?Xaa0yUN z(_dV8In%RgMO5L^Uoe+1fGbVhiEz3$Fj%8KyfH%8K)0ZotX+YU8Dh=Aa-tO|(IKIT z;5tm4*Q*+SrSj@vg*N}f)ENM-E~_U&r^)EhycjYb0NzS7eE`~Re>67nb?ZIOB%u)UiH4^V(z5p7f@&DZXYT8WaaVt&ydY#Cqo7+~#=CSURI>*l5cI)T>a` zMO)stDA~eNa>}98yLOnkXH40x?%tb+YuN!>yPgjoW`L`x?G*)vy!Xwf{6GiZaUF-4 zZkX*YmZ1R2?ss?u7(vhQ+4AkRA1+3*p3EFlH7QV0tmo;I8qAOi{O)t1^=>GPz~9;WZK%ibvBku51rnQwRz`rK{wB< z08dcnfOohC>qA2Q2^?b1%?A7sZGgg=27^G^d_c@W?0a{Y0|399!v8%a00a{tY(eKq z!TYAvPtR3?=9hRL(rf`(I zX%_pp#Hen_dlthagZ_^2f)u-dkl zUv2PPkS-__!VtX}!_(regvzIv!v(CXpEwj0_m&qDWV^m!HfHnf2fXjh)%IumT8ri{ zj`sOM{G`wJW52JnpD-i4@kZ$oX>t2Plm8ODJ=byWBq}WC;oR#>%pHK?-tX$}oZeWr z;+g)VpEA~3CJvn{+~19Y?%@5m{{fzh%oG@8EJg8Lw17cK0Z$89Pyo36U@qW&m;>WK z9plDWp2w|i;y(DvVJfY%kL=@ot3fz356jl5I9^Hs;&|o9>@@Yr7cjatEMdT>P|n1-K;uRFM}{?qa3&-5j&8x4Zu12EeOY z4M2$kin1Eqxvct*pWe=Ul /// Summarises today from the log just written. /// /// Takes the already-read [log] rather than re-reading, so the card can -/// never disagree with the write that produced it. -TodayProgress buildTodayProgress(DayLog log) { +/// never disagree with the write that produced it. [logged] is that write: +/// the card counts up from the totals without it. +TodayProgress buildTodayProgress(DayLog log, {FoodEntry? logged}) { final budget = AppSettingsService.dailyKcalGoal; final today = localDateKey(DateTime.now()); - final entries = (log[today] ?? const []) - .where((entry) => !entry.deleted) - .toList(); + final entries = _live(log[today]); + final before = logged == null ? log : _without(log, logged.id); final macros = sumMacros(entries); return TodayProgress( consumedKcal: sumKcal(entries), @@ -28,8 +30,28 @@ TodayProgress buildTodayProgress(DayLog log) { proteinG: macros.proteinG, carbsG: macros.carbsG, fatG: macros.fatG, - adherenceStreak: adherenceStreak( - statusMap(log, schedule: BudgetHistoryService.schedule), - ), + adherenceStreak: _streak(log), + loggedSlotMinute: logged == null ? null : _slotOf(logged), + loggedKcal: logged?.kcal, + previousKcal: logged == null ? null : sumKcal(_live(before[today])), + previousAdherenceStreak: logged == null ? null : _streak(before), ); } + +int _slotOf(FoodEntry entry) => + entry.slotMinute ?? + slotForLog(DateTime.parse(entry.time), MealScheduleService.current); + +List _live(List? entries) => + (entries ?? const []).where((entry) => !entry.deleted).toList(); + +int _streak(DayLog log) => + adherenceStreak(statusMap(log, schedule: BudgetHistoryService.schedule)); + +DayLog _without(DayLog log, String? id) => { + for (final MapEntry(:key, :value) in log.entries) + key: [ + for (final entry in value) + if (entry.id != id) entry, + ], +}; diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 4184410..77d158e 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -15,6 +15,7 @@ import 'package:diet_guard_app/screens/log_meal_nav_mixin.dart'; import 'package:diet_guard_app/screens/log_meal_progress.dart'; import 'package:diet_guard_app/screens/log_meal_sync_mixin.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_feedback.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/ui/theme.dart'; @@ -25,7 +26,6 @@ import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:diet_guard_app/widgets/sync_health_banner.dart'; import 'package:diet_guard_app/widgets/today_progress_card.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; /// Lets the user log one food item, with food-bank autocomplete and @@ -148,8 +148,8 @@ class _LogMealScreenState extends State return; } // On the tap, before the write/sync: waiting for them is just latency. - unawaited(HapticFeedback.mediumImpact()); - await LogStorageService.instance.logMeal( + LogFeedback.mealLogged(); + final entry = await LogStorageService.instance.logMeal( desc, nutritionFromControllers(_macros, _source), slotMinute: target.slot, @@ -172,7 +172,7 @@ class _LogMealScreenState extends State advanceQueueAfterLog(desc); setState(() { _status = queueStatusLine; - _progress = buildTodayProgress(log); + _progress = buildTodayProgress(log, logged: entry); }); } diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 60af2ce..2cb9669 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -17,6 +17,7 @@ import 'package:diet_guard_app/services/google_sign_in_backend.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:diet_guard_app/widgets/data_backup_row.dart'; +import 'package:diet_guard_app/widgets/log_sound_tile.dart'; import 'package:diet_guard_app/widgets/meal_schedule_editor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -149,11 +150,8 @@ class _SettingsScreenState extends State Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Settings')), - // Centred and capped at the prose width. On the desktop surface this - // ListView spanned the full ~1366px window, so the explanatory - // paragraphs below ran ~180 characters per line -- roughly twice the - // readable limit (tokens.md rule 21) -- and every field stretched with - // them. Invisible on the phone, where the window is already narrow. + // Capped at the prose width: on the ~1366px desktop window the + // paragraphs below ran ~180 chars/line, twice tokens.md rule 21's limit. body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: AppWidth.prose), @@ -178,7 +176,8 @@ class _SettingsScreenState extends State onChanged: _onKcalGoalChanged, ), ), - const SizedBox(height: 24), + const LogSoundTile(), + const SizedBox(height: 16), const Divider(), const SizedBox(height: 8), ..._mealScheduleSection(context), diff --git a/app/lib/services/app_settings_service.dart b/app/lib/services/app_settings_service.dart index 76f2ed9..4470253 100644 --- a/app/lib/services/app_settings_service.dart +++ b/app/lib/services/app_settings_service.dart @@ -33,6 +33,7 @@ class AppSettingsService { final DocumentStore _store; int _dailyKcalGoal = kDefaultDailyKcalGoal; DateTime? _dailyKcalGoalUpdatedAt; + bool _soundOnLog = true; /// Returns the configured daily kcal goal, or the default when /// uninitialised. This is the budget in force *today*; asking what applied @@ -44,6 +45,19 @@ class AppSettingsService { static DateTime? get dailyKcalGoalUpdatedAt => _instance?._dailyKcalGoalUpdatedAt; + /// Whether a logged meal plays the confirm cue; on unless turned off. + /// + /// Device-local, deliberately not synced: a phone in a meeting and a PC + /// with speakers want different answers. + static bool get soundOnLog => _instance?._soundOnLog ?? true; + + /// Persists [on] without touching the budget's edit stamp, which would + /// otherwise make this device win the next budget merge. + Future setSoundOnLog({required bool on}) async { + _soundOnLog = on; + await _writeToDisk(); + } + /// Initialises the singleton against the platform document store (files on /// Android, IndexedDB in the browser-hosted desktop app). static Future init() async { @@ -105,6 +119,9 @@ class AppSettingsService { data['daily_kcal_goal_updated_at'] as String, ); } + if (data is Map && data['sound_on_log'] is bool) { + _soundOnLog = data['sound_on_log'] as bool; + } } on Exception { // Ignore parse errors and keep defaults. } @@ -160,6 +177,7 @@ class AppSettingsService { 'daily_kcal_goal': _dailyKcalGoal, 'daily_kcal_goal_updated_at': _dailyKcalGoalUpdatedAt ?.toIso8601String(), + 'sound_on_log': _soundOnLog, }), ); } diff --git a/app/lib/services/confirm_cue_player.dart b/app/lib/services/confirm_cue_player.dart new file mode 100644 index 0000000..fa7d57b --- /dev/null +++ b/app/lib/services/confirm_cue_player.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +import 'package:audioplayers/audioplayers.dart'; + +AudioPlayer? _player; + +final _mixOverMusicWithoutFocus = AudioContext( + android: const AudioContextAndroid( + contentType: AndroidContentType.sonification, + usageType: AndroidUsageType.assistanceSonification, + audioFocus: AndroidAudioFocus.none, + ), +); + +/// Plays the bundled `confirm` cue through the audioplayers plugin. +Future playConfirmCueAsset() async { + final player = _player ??= AudioPlayer(); + await player.play( + AssetSource('sounds/log_confirm.ogg'), + ctx: _mixOverMusicWithoutFocus, + mode: PlayerMode.lowLatency, + ); +} diff --git a/app/lib/services/log_feedback.dart b/app/lib/services/log_feedback.dart new file mode 100644 index 0000000..88a2f00 --- /dev/null +++ b/app/lib/services/log_feedback.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/confirm_cue_player.dart'; +import 'package:flutter/services.dart'; + +/// `lightImpact` + `confirm` on a recorded meal, whatever its kcal. +abstract final class LogFeedback { + /// Swapped out by test/flutter_test_config.dart. + static Future Function() playConfirmCue = playConfirmCueAsset; + + /// Fires both without awaiting, so the write is not delayed. + static void mealLogged() { + unawaited(HapticFeedback.lightImpact()); + if (AppSettingsService.soundOnLog) { + unawaited(playConfirmCue()); + } + } +} diff --git a/app/lib/ui/theme.dart b/app/lib/ui/theme.dart index 134705f..da5bad7 100644 --- a/app/lib/ui/theme.dart +++ b/app/lib/ui/theme.dart @@ -202,3 +202,39 @@ abstract final class AppWidth { /// phone-portrait test sizes never exercise. static const double shortViewport = 700; } + +/// Motion durations, hand-transcribed from the shared token source +/// `~/src/utils/unified-design-system/DOCS-motion.md`; edit both together. +/// +/// Reduced motion collapses every step to zero via [resolve]. Haptics and +/// sound are not motion and must NOT be routed through this. +abstract final class AppDuration { + /// No animation; the reduced-motion target. + static const Duration instant = Duration.zero; + + /// 120ms -- state change on an element already under the finger. + static const Duration fast = Duration(milliseconds: 120); + + /// 200ms -- the default: things entering or moving within the view. + static const Duration base = Duration(milliseconds: 200); + + /// 320ms -- full-surface change; the ceiling, not a midpoint. + static const Duration slow = Duration(milliseconds: 320); + + /// [d], or [instant] when the OS asks for reduced motion. + static Duration resolve(BuildContext context, Duration d) => + MediaQuery.disableAnimationsOf(context) ? instant : d; +} + +/// Motion easing curves, hand-transcribed from the same token source as +/// [AppDuration] (`DOCS-motion.md`). +abstract final class AppCurve { + /// Default for anything that starts and ends on screen. + static const Cubic standard = Cubic(0.2, 0, 0, 1); + + /// Entering the screen: full speed, eases to rest. + static const Cubic decelerate = Cubic(0, 0, 0, 1); + + /// Leaving the screen: eases in, exits at speed. + static const Cubic accelerate = Cubic(0.3, 0, 1, 1); +} diff --git a/app/lib/widgets/log_sound_tile.dart b/app/lib/widgets/log_sound_tile.dart new file mode 100644 index 0000000..32d60cc --- /dev/null +++ b/app/lib/widgets/log_sound_tile.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:flutter/material.dart'; + +/// Opt-out switch for the log cue, which ships on. +class LogSoundTile extends StatefulWidget { + /// Creates a [LogSoundTile]. + const LogSoundTile({super.key}); + + @override + State createState() => _LogSoundTileState(); +} + +class _LogSoundTileState extends State { + bool _on = AppSettingsService.soundOnLog; + + void _onChanged(bool on) { + setState(() => _on = on); + unawaited(AppSettingsService.instance.setSoundOnLog(on: on)); + } + + @override + Widget build(BuildContext context) => SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Align( + alignment: Alignment.centerLeft, + child: Icon( + _on ? Icons.volume_up : Icons.volume_off, + semanticLabel: 'Sound on log', + ), + ), + value: _on, + onChanged: _onChanged, + ); +} diff --git a/app/lib/widgets/progress_motion.dart b/app/lib/widgets/progress_motion.dart new file mode 100644 index 0000000..a0baace --- /dev/null +++ b/app/lib/widgets/progress_motion.dart @@ -0,0 +1,66 @@ +/// Motion for the post-log progress card: an entrance and a count-up. +library; + +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; + +/// Fades [child] in and slides it up by [AppSpacing.md] when first built. +/// +/// Key it per card instance to replay it; under reduced motion the first +/// frame is already the final one. +class ProgressEntrance extends StatelessWidget { + /// Creates a [ProgressEntrance]. + const ProgressEntrance({required this.child, super.key}); + + /// The widget to bring in. + final Widget child; + + @override + Widget build(BuildContext context) => TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: AppDuration.resolve(context, AppDuration.base), + curve: AppCurve.decelerate, + child: child, + builder: (context, t, child) => Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, (1 - t) * AppSpacing.md), + child: child, + ), + ), + ); +} + +/// Drives [builder] with a number running from [from] to [to]. +/// +/// Neutral on purpose: it never knows whether the number is "good", so no +/// value gets a different curve or colour for being under budget. The last +/// frame passes [to] untouched, so callers render exactly the static value. +class CountUp extends StatelessWidget { + /// Creates a [CountUp]. + const CountUp({ + required this.from, + required this.to, + required this.builder, + super.key, + }); + + /// Starting value. + final double from; + + /// Final value. + final double to; + + /// Builds the UI for the current animated value. + final Widget Function(BuildContext context, double value) builder; + + @override + Widget build(BuildContext context) => TweenAnimationBuilder( + tween: Tween(begin: from, end: to), + duration: from == to + ? Duration.zero + : AppDuration.resolve(context, AppDuration.slow), + curve: AppCurve.standard, + builder: (context, value, _) => builder(context, value), + ); +} diff --git a/app/lib/widgets/today_progress_card.dart b/app/lib/widgets/today_progress_card.dart index ed3e44a..ac06706 100644 --- a/app/lib/widgets/today_progress_card.dart +++ b/app/lib/widgets/today_progress_card.dart @@ -2,7 +2,9 @@ /// today's standing against the daily budget. library; +import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/ui/theme.dart'; +import 'package:diet_guard_app/widgets/progress_motion.dart'; import 'package:flutter/material.dart'; /// Everything [TodayProgressCard] renders, computed by the caller. @@ -21,6 +23,10 @@ class TodayProgress { required this.carbsG, required this.fatG, required this.adherenceStreak, + this.loggedSlotMinute, + this.loggedKcal, + this.previousKcal, + this.previousAdherenceStreak, }); /// Today's total calories, including the meal just logged. @@ -42,6 +48,19 @@ class TodayProgress { /// Consecutive-day budget-adherence streak, as of today. final int adherenceStreak; + /// Slot (minute of day) of the meal just logged; null hides the + /// confirmation line. + final int? loggedSlotMinute; + + /// Calories of the meal just logged, exactly as stored. + final double? loggedKcal; + + /// Today's total before this meal; null means no count-up. + final double? previousKcal; + + /// Streak before this meal; null means it equals [adherenceStreak]. + final int? previousAdherenceStreak; + /// Calories still available today; negative once over budget. double get remainingKcal => budgetKcal - consumedKcal; @@ -72,66 +91,91 @@ class TodayProgressCard extends StatelessWidget { static String _grams(double value) => '${value.round()}g'; + /// Whole numbers without decimals; fractions exactly, never rounded. + static String _kcal(double value) => + value == value.truncateToDouble() ? '${value.toInt()}' : '$value'; + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final colors = theme.colorScheme; - final remaining = progress.remainingKcal.abs().round(); - final remainingLabel = progress.isOverBudget - ? '$remaining over' - : '$remaining left'; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - // Fill-only elevation: the shared design system forbids shadows in - // dark UI, and mixing a border with a fill on the same surface. - color: colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ + final slot = progress.loggedSlotMinute; + final logged = progress.loggedKcal; + final from = progress.previousKcal ?? progress.consumedKcal; + final streakFrom = + progress.previousAdherenceStreak ?? progress.adherenceStreak; + return ProgressEntrance( + key: ObjectKey(progress), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + // Fill-only elevation: the shared design system forbids shadows in + // dark UI, and mixing a border with a fill on the same surface. + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slot != null && logged != null) ...[ Text( - '${progress.consumedKcal.round()} / ${progress.budgetKcal}', - style: theme.textTheme.titleMedium?.copyWith( - color: progress.isOverBudget - ? colors.error - : colors.onSurface, - ), - ), - const SizedBox(width: AppSpacing.xs), - Text('kcal', style: theme.textTheme.labelSmall), - const Spacer(), - Text( - remainingLabel, - style: theme.textTheme.labelMedium?.copyWith( - color: progress.isOverBudget - ? colors.error - : colors.onSurfaceVariant, - ), + '✓ ${slotLabel(slot)} · ${_kcal(logged)} kcal', + style: theme.textTheme.labelMedium, ), + const SizedBox(height: AppSpacing.xs), ], - ), - const SizedBox(height: AppSpacing.xs), - Text( - 'P ${_grams(progress.proteinG)} · ' - 'C ${_grams(progress.carbsG)} · ' - 'F ${_grams(progress.fatG)}', - style: theme.textTheme.labelSmall, - ), - const SizedBox(height: AppSpacing.xs), - Text( - 'Adherence streak: ${progress.adherenceStreak} ' - '${_plural(progress.adherenceStreak)}', - style: theme.textTheme.labelSmall, - ), - ], + CountUp(from: from, to: progress.consumedKcal, builder: _kcalRow), + const SizedBox(height: AppSpacing.xs), + Text( + 'P ${_grams(progress.proteinG)} · ' + 'C ${_grams(progress.carbsG)} · ' + 'F ${_grams(progress.fatG)}', + style: theme.textTheme.labelSmall, + ), + const SizedBox(height: AppSpacing.xs), + CountUp( + from: streakFrom.toDouble(), + to: progress.adherenceStreak.toDouble(), + builder: (context, value) { + final days = value.round(); + return Text( + 'Adherence streak: $days ${_plural(days)}', + style: theme.textTheme.labelSmall, + ); + }, + ), + ], + ), ), ); } + + /// The ` / ` and `N left`/`N over` row at [consumed]. + Widget _kcalRow(BuildContext context, double consumed) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final remaining = progress.budgetKcal - consumed; + final over = remaining < 0; + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '${consumed.round()} / ${progress.budgetKcal}', + style: theme.textTheme.titleMedium?.copyWith( + color: over ? colors.error : colors.onSurface, + ), + ), + const SizedBox(width: AppSpacing.xs), + Text('kcal', style: theme.textTheme.labelSmall), + const Spacer(), + Text( + '${remaining.abs().round()} ${over ? 'over' : 'left'}', + style: theme.textTheme.labelMedium?.copyWith( + color: over ? colors.error : colors.onSurfaceVariant, + ), + ), + ], + ); + } } diff --git a/app/pubspec.lock b/app/pubspec.lock index 52095e6..c50662b 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -33,6 +33,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index cf03a16..605a2d2 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,12 +2,13 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.131+1 +version: 1.0.132+1 environment: sdk: ^3.12.2 dependencies: + audioplayers: 6.8.1 crdt_sync: git: url: https://github.com/kuhyx/utils @@ -77,6 +78,8 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/sounds/log_confirm.ogg # Launcher icon generation. Source art is produced by the shared generator: # PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons generate --app diff --git a/app/test/flutter_test_config.dart b/app/test/flutter_test_config.dart new file mode 100644 index 0000000..eb89cba --- /dev/null +++ b/app/test/flutter_test_config.dart @@ -0,0 +1,10 @@ +import 'dart:async'; + +import 'package:diet_guard_app/services/log_feedback.dart'; + +/// Mutes the log cue suite-wide: audioplayers has no platform side under +/// `flutter test`, so a real play would fail any test that logs a meal. +Future testExecutable(FutureOr Function() testMain) async { + LogFeedback.playConfirmCue = () async {}; + await testMain(); +} diff --git a/app/test/screens/log_meal_haptic_test.dart b/app/test/screens/log_meal_haptic_test.dart index 722f7a7..505a9a7 100644 --- a/app/test/screens/log_meal_haptic_test.dart +++ b/app/test/screens/log_meal_haptic_test.dart @@ -6,6 +6,7 @@ import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_feedback.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:flutter/material.dart'; @@ -15,9 +16,12 @@ import 'package:flutter_test/flutter_test.dart'; void main() { late Directory dir; final haptics = []; + var cues = 0; setUp(() async { haptics.clear(); + cues = 0; + LogFeedback.playConfirmCue = () async => cues++; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, (call) async { if (call.method == 'HapticFeedback.vibrate') { @@ -34,6 +38,7 @@ void main() { }); tearDown(() async { + LogFeedback.playConfirmCue = () async {}; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, null); LogStorageService.resetForTesting(); @@ -58,9 +63,27 @@ void main() { await tester.tap(find.byTooltip('Log meal')); // One frame, no I/O wait: the haptic must not wait for the write. await tester.pump(); - expect(haptics, ['HapticFeedbackType.mediumImpact']); + expect(haptics, ['HapticFeedbackType.lightImpact']); + expect(cues, 1); await settle(tester); expect(haptics, hasLength(1)); + expect(cues, 1); + }); + }); + + testWidgets('sound off silences the cue but keeps the haptic', ( + tester, + ) async { + await tester.runAsync(() async { + await AppSettingsService.instance.setSoundOnLog(on: false); + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + await tester.enterText(find.byType(TextField).at(0), 'toast'); + await settle(tester); + await tester.tap(find.byTooltip('Log meal')); + await settle(tester); + expect(haptics, ['HapticFeedbackType.lightImpact']); + expect(cues, 0); }); }); @@ -74,6 +97,7 @@ void main() { await settle(tester); expect(find.text('Type what you ate first.'), findsOneWidget); expect(haptics, isEmpty); + expect(cues, 0); }); }); } diff --git a/app/test/screens/log_meal_progress_test.dart b/app/test/screens/log_meal_progress_test.dart new file mode 100644 index 0000000..f72bf0d --- /dev/null +++ b/app/test/screens/log_meal_progress_test.dart @@ -0,0 +1,40 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/screens/log_meal_progress.dart'; +import 'package:diet_guard_app/services/meal_schedule_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry _entry({int? slotMinute}) => FoodEntry( + time: DateTime.now().toIso8601String(), + desc: 'oats', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 50, + fatG: 5, + source: 'manual', + id: 'e1', + slotMinute: slotMinute, +); + +void main() { + test('the logged slot is the stamped one when the entry carries it', () { + final entry = _entry(slotMinute: 480); + final progress = buildTodayProgress({ + localDateKey(DateTime.now()): [entry], + }, logged: entry); + expect(progress.loggedSlotMinute, 480); + }); + + test('an unstamped entry falls back to the slot its time maps to', () { + final entry = _entry(); + final progress = buildTodayProgress({ + localDateKey(DateTime.now()): [entry], + }, logged: entry); + expect( + progress.loggedSlotMinute, + slotForLog(DateTime.parse(entry.time), MealScheduleService.current), + ); + }); +} diff --git a/app/test/services/app_settings_sound_test.dart b/app/test/services/app_settings_sound_test.dart new file mode 100644 index 0000000..ae13436 --- /dev/null +++ b/app/test/services/app_settings_sound_test.dart @@ -0,0 +1,65 @@ +import 'dart:io'; + +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('diet_guard_sound_'); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(dir)); + }); + + tearDown(() async { + AppSettingsService.resetForTesting(); + BudgetHistoryService.resetForTesting(); + await dir.delete(recursive: true); + }); + + Future reload() { + AppSettingsService.resetForTesting(); + return AppSettingsService.initForTesting(FileDocumentStore(dir)); + } + + test('sound on log ships on, even uninitialised', () async { + AppSettingsService.resetForTesting(); + expect(AppSettingsService.soundOnLog, isTrue); + await reload(); + expect(AppSettingsService.soundOnLog, isTrue); + }); + + test('turning it off survives a restart', () async { + final svc = await reload(); + await svc.setSoundOnLog(on: false); + await reload(); + expect(AppSettingsService.soundOnLog, isFalse); + }); + + test('a kcal goal save keeps the sound setting', () async { + final svc = await reload(); + await svc.setSoundOnLog(on: false); + await svc.saveDailyKcalGoal(1900); + await reload(); + expect(AppSettingsService.soundOnLog, isFalse); + expect(AppSettingsService.dailyKcalGoal, 1900); + }); + + test('toggling sound does not restamp the synced budget', () async { + final svc = await reload(); + await svc.applySyncedBudget(2000, updatedAt: DateTime.utc(2026)); + await svc.setSoundOnLog(on: false); + await reload(); + expect(AppSettingsService.dailyKcalGoalUpdatedAt, DateTime.utc(2026)); + }); + + test('a non-bool stored value falls back to on', () async { + await FileDocumentStore( + dir, + ).write(AppSettingsService.documentName, '{"sound_on_log": "no"}'); + await reload(); + expect(AppSettingsService.soundOnLog, isTrue); + }); +} diff --git a/app/test/ui/theme_motion_test.dart b/app/test/ui/theme_motion_test.dart new file mode 100644 index 0000000..2a6aba5 --- /dev/null +++ b/app/test/ui/theme_motion_test.dart @@ -0,0 +1,47 @@ +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('durations follow the shared motion scale', () { + expect(AppDuration.instant, Duration.zero); + expect(AppDuration.fast, const Duration(milliseconds: 120)); + expect(AppDuration.base, const Duration(milliseconds: 200)); + expect(AppDuration.slow, const Duration(milliseconds: 320)); + }); + + test('curves carry the shared control points', () { + expect(AppCurve.standard, const Cubic(0.2, 0, 0, 1)); + expect(AppCurve.decelerate, const Cubic(0, 0, 0, 1)); + expect(AppCurve.accelerate, const Cubic(0.3, 0, 1, 1)); + }); + + Future resolved( + WidgetTester tester, { + required bool reduced, + }) async { + late Duration out; + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: Builder( + builder: (context) { + out = AppDuration.resolve(context, AppDuration.slow); + return const SizedBox(); + }, + ), + ), + ); + return out; + } + + testWidgets('resolve collapses to zero under disableAnimations', ( + tester, + ) async { + expect(await resolved(tester, reduced: true), Duration.zero); + }); + + testWidgets('resolve returns the token otherwise', (tester) async { + expect(await resolved(tester, reduced: false), AppDuration.slow); + }); +} diff --git a/app/test/widgets/log_sound_tile_test.dart b/app/test/widgets/log_sound_tile_test.dart new file mode 100644 index 0000000..632c30e --- /dev/null +++ b/app/test/widgets/log_sound_tile_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:diet_guard_app/screens/settings_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/meal_schedule_service.dart'; +import 'package:diet_guard_app/widgets/log_sound_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../fake_secure_storage.dart'; + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('diet_guard_sound_tile_'); + LogStorageService.resetForTesting(store: FileDocumentStore(dir)); + FoodBankService.resetForTesting(store: FileDocumentStore(dir)); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(dir)); + await AppSettingsService.initForTesting(FileDocumentStore(dir)); + await MealScheduleService.initForTesting(FileDocumentStore(dir)); + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + AppSettingsService.resetForTesting(); + BudgetHistoryService.resetForTesting(); + MealScheduleService.resetForTesting(); + await dir.delete(recursive: true); + }); + + testWidgets('the switch reflects and persists the setting', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: LogSoundTile())), + ); + final tile = find.byType(SwitchListTile); + expect(find.bySemanticsLabel('Sound on log'), findsOneWidget); + expect(tester.widget(tile).value, isTrue); + + await tester.tap(tile); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pump(); + expect(tester.widget(tile).value, isFalse); + + AppSettingsService.resetForTesting(); + await AppSettingsService.initForTesting(FileDocumentStore(dir)); + expect(AppSettingsService.soundOnLog, isFalse); + }); + }); + + testWidgets('settings screen carries the toggle', (tester) async { + await tester.runAsync(() async { + tester.view.physicalSize = const Size(800, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + expect(find.byType(LogSoundTile), findsOneWidget); + }); + }); +} diff --git a/app/test/widgets/today_progress_card_motion_test.dart b/app/test/widgets/today_progress_card_motion_test.dart new file mode 100644 index 0000000..0991548 --- /dev/null +++ b/app/test/widgets/today_progress_card_motion_test.dart @@ -0,0 +1,153 @@ +import 'package:diet_guard_app/widgets/today_progress_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +TodayProgress _progress({ + double consumedKcal = 1450, + double? previousKcal, + int adherenceStreak = 3, + int? previousAdherenceStreak, + int? loggedSlotMinute, + double? loggedKcal, +}) => TodayProgress( + consumedKcal: consumedKcal, + budgetKcal: 2200, + proteinG: 90, + carbsG: 120, + fatG: 40, + adherenceStreak: adherenceStreak, + previousKcal: previousKcal, + previousAdherenceStreak: previousAdherenceStreak, + loggedSlotMinute: loggedSlotMinute, + loggedKcal: loggedKcal, +); + +Future _pump( + WidgetTester tester, + TodayProgress progress, { + bool reduced = false, +}) => tester.pumpWidget( + MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: MaterialApp( + home: Scaffold(body: TodayProgressCard(progress: progress)), + ), + ), +); + +double _entranceOpacity(WidgetTester tester) => tester + .widget( + find + .descendant( + of: find.byType(TodayProgressCard), + matching: find.byType(Opacity), + ) + .first, + ) + .opacity; + +void main() { + group('confirmation line', () { + testWidgets('prints whole kcal without decimals', (tester) async { + await _pump(tester, _progress(loggedSlotMinute: 720, loggedKcal: 450)); + expect(find.text('✓ 12:00 · 450 kcal'), findsOneWidget); + expect(find.textContaining('Logged'), findsNothing); + }); + + testWidgets('keeps fractional kcal exact', (tester) async { + await _pump(tester, _progress(loggedSlotMinute: 495, loggedKcal: 450.5)); + expect(find.text('✓ 08:15 · 450.5 kcal'), findsOneWidget); + }); + + testWidgets('is absent when the logged fields are null', (tester) async { + await _pump(tester, _progress()); + expect(find.textContaining('✓'), findsNothing); + await _pump(tester, _progress(loggedSlotMinute: 720)); + expect(find.textContaining('✓'), findsNothing); + await _pump(tester, _progress(loggedKcal: 450)); + expect(find.textContaining('✓'), findsNothing); + }); + }); + + group('count-up', () { + testWidgets('runs from previousKcal to consumedKcal', (tester) async { + await _pump(tester, _progress(previousKcal: 1000)); + expect(find.text('1000 / 2200'), findsOneWidget); + expect(find.text('1200 left'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 160)); + final mid = tester.widget(find.textContaining(' / 2200')).data!; + final value = int.parse(mid.split(' / ').first); + expect(value, greaterThan(1000)); + expect(value, lessThan(1450)); + + await tester.pumpAndSettle(); + expect(find.text('1450 / 2200'), findsOneWidget); + expect(find.text('750 left'), findsOneWidget); + }); + + testWidgets('is static without previousKcal', (tester) async { + await _pump(tester, _progress()); + expect(find.text('1450 / 2200'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 160)); + expect(find.text('1450 / 2200'), findsOneWidget); + }); + + testWidgets('over-budget label and colour follow the animation', ( + tester, + ) async { + await _pump(tester, _progress(consumedKcal: 2500, previousKcal: 2000)); + expect(find.text('200 left'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.text('2500 / 2200'), findsOneWidget); + expect(find.text('300 over'), findsOneWidget); + }); + + testWidgets('streak counts up when it changed', (tester) async { + await _pump(tester, _progress(previousAdherenceStreak: 2)); + expect(find.text('Adherence streak: 2 days'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.text('Adherence streak: 3 days'), findsOneWidget); + }); + + testWidgets('streak is static when unchanged', (tester) async { + await _pump(tester, _progress(previousAdherenceStreak: 3)); + expect(find.text('Adherence streak: 3 days'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 160)); + expect(find.text('Adherence streak: 3 days'), findsOneWidget); + }); + }); + + group('reduced motion', () { + testWidgets('first frame shows the final values at full opacity', ( + tester, + ) async { + await _pump( + tester, + _progress(previousKcal: 1000, previousAdherenceStreak: 2), + reduced: true, + ); + expect(find.text('1450 / 2200'), findsOneWidget); + expect(find.text('750 left'), findsOneWidget); + expect(find.text('Adherence streak: 3 days'), findsOneWidget); + expect(_entranceOpacity(tester), 1); + }); + }); + + group('entrance', () { + testWidgets('starts transparent and ends opaque', (tester) async { + await _pump(tester, _progress()); + expect(_entranceOpacity(tester), lessThan(1)); + await tester.pumpAndSettle(); + expect(_entranceOpacity(tester), 1); + }); + + testWidgets('replays for a new TodayProgress instance', (tester) async { + await _pump(tester, _progress()); + await tester.pumpAndSettle(); + await _pump(tester, _progress()); + expect(_entranceOpacity(tester), lessThan(1)); + await tester.pumpAndSettle(); + }); + }); +}