mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
Add log sound cue and progress card motion
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWVHMLzeojNP4AaPoEj
This commit is contained in:
parent
64841009a5
commit
97af520fdd
BIN
app/assets/sounds/log_confirm.ogg
Normal file
BIN
app/assets/sounds/log_confirm.ogg
Normal file
Binary file not shown.
@ -1,9 +1,11 @@
|
||||
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/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/day_status_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/today_progress_card.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@ -14,13 +16,13 @@ double parseMacroField(TextEditingController controller) =>
|
||||
/// 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 <FoodEntry>[])
|
||||
.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<FoodEntry> _live(List<FoodEntry>? entries) =>
|
||||
(entries ?? const <FoodEntry>[]).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,
|
||||
],
|
||||
};
|
||||
|
||||
@ -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<LogMealScreen>
|
||||
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<LogMealScreen>
|
||||
advanceQueueAfterLog(desc);
|
||||
setState(() {
|
||||
_status = queueStatusLine;
|
||||
_progress = buildTodayProgress(log);
|
||||
_progress = buildTodayProgress(log, logged: entry);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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<SettingsScreen>
|
||||
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<SettingsScreen>
|
||||
onChanged: _onKcalGoalChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const LogSoundTile(),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
..._mealScheduleSection(context),
|
||||
|
||||
@ -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<void> 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<AppSettingsService> 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
22
app/lib/services/confirm_cue_player.dart
Normal file
22
app/lib/services/confirm_cue_player.dart
Normal file
@ -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<void> playConfirmCueAsset() async {
|
||||
final player = _player ??= AudioPlayer();
|
||||
await player.play(
|
||||
AssetSource('sounds/log_confirm.ogg'),
|
||||
ctx: _mixOverMusicWithoutFocus,
|
||||
mode: PlayerMode.lowLatency,
|
||||
);
|
||||
}
|
||||
19
app/lib/services/log_feedback.dart
Normal file
19
app/lib/services/log_feedback.dart
Normal file
@ -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<void> Function() playConfirmCue = playConfirmCueAsset;
|
||||
|
||||
/// Fires both without awaiting, so the write is not delayed.
|
||||
static void mealLogged() {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
if (AppSettingsService.soundOnLog) {
|
||||
unawaited(playConfirmCue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
36
app/lib/widgets/log_sound_tile.dart
Normal file
36
app/lib/widgets/log_sound_tile.dart
Normal file
@ -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<LogSoundTile> createState() => _LogSoundTileState();
|
||||
}
|
||||
|
||||
class _LogSoundTileState extends State<LogSoundTile> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
66
app/lib/widgets/progress_motion.dart
Normal file
66
app/lib/widgets/progress_motion.dart
Normal file
@ -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<double>(
|
||||
tween: Tween<double>(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<double>(
|
||||
tween: Tween<double>(begin: from, end: to),
|
||||
duration: from == to
|
||||
? Duration.zero
|
||||
: AppDuration.resolve(context, AppDuration.slow),
|
||||
curve: AppCurve.standard,
|
||||
builder: (context, value, _) => builder(context, value),
|
||||
);
|
||||
}
|
||||
@ -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 `<consumed> / <budget>` 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 <key>
|
||||
|
||||
10
app/test/flutter_test_config.dart
Normal file
10
app/test/flutter_test_config.dart
Normal file
@ -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<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
LogFeedback.playConfirmCue = () async {};
|
||||
await testMain();
|
||||
}
|
||||
@ -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 = <String>[];
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
40
app/test/screens/log_meal_progress_test.dart
Normal file
40
app/test/screens/log_meal_progress_test.dart
Normal file
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
65
app/test/services/app_settings_sound_test.dart
Normal file
65
app/test/services/app_settings_sound_test.dart
Normal file
@ -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<AppSettingsService> 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);
|
||||
});
|
||||
}
|
||||
47
app/test/ui/theme_motion_test.dart
Normal file
47
app/test/ui/theme_motion_test.dart
Normal file
@ -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<Duration> 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);
|
||||
});
|
||||
}
|
||||
72
app/test/widgets/log_sound_tile_test.dart
Normal file
72
app/test/widgets/log_sound_tile_test.dart
Normal file
@ -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<SwitchListTile>(tile).value, isTrue);
|
||||
|
||||
await tester.tap(tile);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
await tester.pump();
|
||||
expect(tester.widget<SwitchListTile>(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<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(LogSoundTile), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
153
app/test/widgets/today_progress_card_motion_test.dart
Normal file
153
app/test/widgets/today_progress_card_motion_test.dart
Normal file
@ -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<void> _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<Opacity>(
|
||||
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<Text>(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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user