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

- 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:
Krzysztof Rudnicki 2026-11-02 21:12:46 +01:00
parent 64841009a5
commit 97af520fdd
20 changed files with 805 additions and 73 deletions

Binary file not shown.

View File

@ -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,
],
};

View File

@ -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);
});
}

View File

@ -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),

View File

@ -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,
}),
);
}

View 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,
);
}

View 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());
}
}
}

View File

@ -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);
}

View 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,
);
}

View 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),
);
}

View File

@ -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,
),
),
],
);
}
}

View File

@ -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:

View File

@ -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>

View 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();
}

View File

@ -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);
});
});
}

View 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),
);
});
}

View 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);
});
}

View 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);
});
}

View 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);
});
});
}

View 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();
});
});
}