Compare commits

...

19 Commits

Author SHA1 Message Date
ded7486244 Fix lints in error icon page
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:21:46 +01:00
8068205d0c Page loads of errors show an icon page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:16:04 +01:00
91a9ee5295 Proxy errors load as an empty page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:01:22 +01:00
0982565fdc Rename existing reminder channel on start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:56:27 +01:00
957ddc7ddc Workout rows show a kind icon, not label
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:17:09 +01:00
e7ef648757 Tag manual workouts with source manual
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:01:52 +01:00
dc2799e36a Bump four app deps to newest stable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:01:51 +01:00
2dc502bbb6 Speak formula names, fit sheet value
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:11:23 +01:00
c9c30b1ee5 Glyph and count for catering cues
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:11:23 +01:00
ff12f4e171 Guard app screens and wrapper against words
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:11:23 +01:00
43e4b4070d Replace PC gate text with glyphs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:35:34 +01:00
45627f7075 Bump pytest-randomly to 5.1.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
2026-11-09 10:24:25 +01:00
60ce1a6d71 Icons for formulas, fit tile value, channel name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
2026-11-09 10:04:42 +01:00
445712dea3 Hide the log-date glyph on today
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
2026-11-09 08:29:15 +01:00
752c3704b2 Drop text from the body screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
2026-11-09 08:21:13 +01:00
f25d22da20 Drop text from settings, GitHub, alerts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
2026-11-09 08:21:06 +01:00
737d09d37c Drop text from history, food bank, calendar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
2026-11-09 08:21:05 +01:00
83d05c637a Drop text from the log-meal screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
2026-11-09 08:20:57 +01:00
07e5d2149c Add text-free cue widgets and finders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
2026-11-09 08:20:50 +01:00
164 changed files with 5812 additions and 2477 deletions

View File

@ -0,0 +1,35 @@
/// A wordless page for an error the window itself loaded: Chrome would
/// otherwise paint its own "This page isn't working" sentences over an
/// empty error body. Only the title carries the app's name (OS-level label).
library;
import 'dart:io';
/// HTML page with only an icon — no words painted by the browser.
const String errorIconPage =
'<!doctype html><html><head><meta charset="utf-8"> '
'<title>Diet Guard</title> '
'<style>html,body{margin:0;height:100%; '
'background:#211D1B;display:flex;align-items:center; '
'justify-content:center}</style></head><body> '
'<svg width="96" height="96" viewBox="0 0 24 24" fill="none" '
'stroke="#A39892" stroke-width="2" stroke-linecap="round" '
'aria-hidden="true"><circle cx="12" cy="12" r="9"/> '
'<line x1="5.6" y1="18.4" x2="18.4" y2="5.6"/></svg></body></html>';
/// Whether [request] is the window loading a page, not the app's fetch().
bool isNavigation(HttpRequest request) =>
request.headers.value('sec-fetch-mode') == 'navigate';
/// Replaces an error answer to a page load with [errorIconPage]. Does
/// nothing for fetch() calls, successes, or a response already written.
void paintErrorIcon(HttpRequest request) {
final response = request.response;
if (!isNavigation(request) || response.statusCode < 400) return;
try {
response.headers.contentType = ContentType.html;
response.write(errorIconPage);
} on Exception {
return; // body already sent: leave it alone
}
}

View File

@ -19,6 +19,7 @@ library;
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/desktop/error_page.dart' show isNavigation;
import 'package:http/http.dart' as http;
/// GitHub's REST API base, which [GitHubProxy] fronts.
@ -177,8 +178,13 @@ class GitHubProxy {
}
void _passThrough(HttpRequest request, http.Response response) {
request.response.statusCode = response.statusCode;
if (isNavigation(request)) {
// Top-level navigation (window load) must not paint error JSON; the app's
// own fetch() calls never send 'navigate', so they still get the body.
return;
}
request.response
..statusCode = response.statusCode
..headers.contentType = ContentType.json
..add(response.bodyBytes);
}

View File

@ -24,6 +24,7 @@ import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/desktop/content_types.dart' as content_types;
import 'package:diet_guard_app/desktop/error_page.dart';
import 'package:diet_guard_app/desktop/github_proxy.dart';
import 'package:diet_guard_app/services/desktop_wrapper.dart';
import 'package:path/path.dart' as p;
@ -91,6 +92,7 @@ class WrapperServer {
} on Exception {
request.response.statusCode = HttpStatus.internalServerError;
}
paintErrorIcon(request);
await request.response.close();
}
}

View File

@ -18,6 +18,8 @@ import 'package:diet_guard_app/services/body_prefs.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/services/health_steps.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/activity_card.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
@ -109,22 +111,13 @@ class _BodyScreenState extends State<BodyScreen> {
final (exercise, _, _) = bodyExercise(body, bio, today);
final tiles = _tiles(body, bio, exercise);
return Scaffold(
appBar: AppBar(title: const Text('Body')),
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BodyTileGrid(tiles: tiles),
const SizedBox(height: AppSpacing.sm),
BodyNote(
'Age ${bodyAgeText(body, today)} · synced with the PC.',
),
],
),
child: BodyTileGrid(tiles: tiles),
),
),
),
@ -159,8 +152,12 @@ class _BodyScreenState extends State<BodyScreen> {
) {
final weight = body.latestWeight?.$2;
final bodyFat = body.latestBodyFat?.$2;
final age = bodyAgeText(body, DateTime.now());
final profile = BodyTileSpec(
title: 'Profile',
semanticLabel: 'Profile',
icon: Icons.person,
value: age,
valueIcon: age == null ? null : Icons.cake,
builder: (_) => ProfileCard(onSaved: _onChanged),
);
final goal = bio == null
@ -174,45 +171,65 @@ class _BodyScreenState extends State<BodyScreen> {
return [
if (_profileAtTop) profile,
BodyTileSpec(
title: 'Daily calorie target',
value: goal == null ? null : '$goal kcal',
semanticLabel: 'Daily calorie target',
icon: Icons.flag,
value: goal == null ? null : '$goal',
valueIcon: goal == null ? null : MacroKind.kcal.icon,
builder: (_) => bio == null
? BodySection(
title: 'Daily calorie target',
children: [BodyNote(bodyMissingNote(body))],
semanticLabel: 'Daily calorie target',
children: [
Row(
children: [
for (final icon in bodyMissingIcons(body))
StatusCue(
kind: CueKind.warn,
icon: icon,
semanticLabel: bodyMissingNote(body),
),
],
),
],
)
: GoalCard(bio: bio, exercise: exercise, bodyFatPct: bodyFat),
),
BodyTileSpec(
title: 'Weight',
semanticLabel: 'Weight',
icon: Icons.monitor_weight,
value: weight == null ? null : formatKg(weight),
builder: (_) => WeightCard(onChanged: _onChanged),
),
if (bio != null) ...[
BodyTileSpec(
title: 'BMI',
semanticLabel: 'BMI',
icon: Icons.accessibility_new,
value: oneDecimal(bmiReport(bio).bmi),
builder: (_) => BmiCard(bio: bio),
),
BodyTileSpec(
title: 'Ideal weight',
semanticLabel: 'Ideal weight',
icon: Icons.balance,
builder: (_) => IdealWeightCard(bio: bio),
),
BodyTileSpec(
title: 'Resting metabolism (BMR)',
value: '${whole(tableBmr(bio, bodyFat).value!)} kcal',
semanticLabel: 'Resting metabolism (BMR)',
icon: Icons.bedtime,
value: whole(tableBmr(bio, bodyFat).value!),
valueIcon: MacroKind.kcal.icon,
builder: (_) => BmrCard(bio: bio, bodyFatPct: bodyFat),
),
],
BodyTileSpec(
title: 'Activity',
semanticLabel: 'Activity',
icon: Icons.directions_run,
builder: (_) => ValueListenableBuilder<bool>(
valueListenable: _canConnectSteps,
builder: (_, canConnect, _) => _activityCard(canConnect: canConnect),
),
),
BodyTileSpec(
title: 'Week planner (info only)',
semanticLabel: 'Week planner',
icon: Icons.calendar_view_week,
builder: (_) => WeekPlanCard(
defaultTarget: AppSettingsService.dailyKcalGoal,
logged: _logged,
@ -220,7 +237,8 @@ class _BodyScreenState extends State<BodyScreen> {
),
if (!_profileAtTop) profile,
BodyTileSpec(
title: 'Body fat',
semanticLabel: 'Body fat',
icon: Icons.percent,
value: bodyFat == null ? null : '${oneDecimal(bodyFat)} %',
builder: (_) => BodyFatCard(onChanged: _onChanged),
),

View File

@ -6,6 +6,7 @@ import 'package:diet_guard_app/services/body_calc.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/services/body_steps.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart' show IconData, Icons;
/// Builds the person from the stored profile and newest weight, or null
/// when anything the formulas need is missing.
@ -61,7 +62,16 @@ Map<String, double> kcalByDay(DayLog log) => {
.fold<double>(0, (sum, food) => sum + food.kcal),
};
/// Why the numbers are missing.
/// Glyphs of the inputs still missing (profile, weight), in the order the
/// user should supply them; the visible counterpart of [bodyMissingNote].
List<IconData> bodyMissingIcons(BodyService body) => [
if (body.birth == null || body.heightCm == null || body.sex == null)
Icons.person,
if (body.latestWeight == null) Icons.monitor_weight,
];
/// Spoken reason the numbers are missing (screen readers, tests; never
/// painted).
String bodyMissingNote(BodyService body) {
if (body.latestWeight == null && body.birth == null) {
return 'Set your profile and enter your weight to see BMI, ideal '
@ -71,8 +81,8 @@ String bodyMissingNote(BodyService body) {
return 'Set your profile (birth date, height, sex) to see the numbers.';
}
/// The age line value.
String bodyAgeText(BodyService body, DateTime today) {
/// The age in whole years, or null while no birth date is stored.
String? bodyAgeText(BodyService body, DateTime today) {
final birth = DateTime.tryParse(body.birth ?? '');
return birth == null ? 'unknown' : '${ageOn(birth, today)}';
return birth == null ? null : '${ageOn(birth, today)}';
}

View File

@ -17,6 +17,8 @@ import 'package:diet_guard_app/services/average_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/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/widgets/average_summary_row.dart';
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
import 'package:diet_guard_app/widgets/streak_summary_row.dart';
@ -153,9 +155,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Calendar')),
appBar: AppBar(),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
@ -168,11 +169,10 @@ class _CalendarScreenState extends State<CalendarScreen> {
if (_budgetStatus != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_budgetStatus!,
style: TextStyle(
color: _budgetError ? theme.colorScheme.error : null,
),
child: StatusCue(
key: ValueKey(_budgetStatus),
kind: _budgetError ? CueKind.error : CueKind.ok,
semanticLabel: _budgetStatus!,
),
),
const SizedBox(height: 12),
@ -215,7 +215,11 @@ class _BudgetRow extends StatelessWidget {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Daily budget (kcal):'),
Icon(
MacroKind.kcal.icon,
color: MacroKind.kcal.color,
semanticLabel: 'Daily budget (kcal)',
),
const SizedBox(width: 8),
SizedBox(
width: 90,
@ -230,7 +234,10 @@ class _BudgetRow extends StatelessWidget {
const SizedBox(width: 8),
FilledButton(
onPressed: onEditOrSave,
child: Text(editing ? 'Save' : 'Edit'),
child: Icon(
editing ? Icons.check : Icons.edit,
semanticLabel: editing ? 'Save' : 'Edit',
),
),
],
);

View File

@ -8,6 +8,7 @@ import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:diet_guard_app/models/nutrition.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/ui/cues.dart';
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:flutter/material.dart';
@ -35,6 +36,7 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
List<FoodSuggestion> _suggestions = const [];
String _source = 'manual';
String? _status;
int _rejections = 0;
@override
void initState() {
@ -80,9 +82,7 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
}
Future<void> _onDescChanged() async {
final matches = await FoodBankService.instance.search(
_descController.text,
);
final matches = await FoodBankService.instance.search(_descController.text);
if (!mounted) return;
setState(() => _suggestions = matches);
}
@ -104,7 +104,10 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
Future<void> _onSave() async {
final desc = _descController.text.trim();
if (desc.isEmpty) {
setState(() => _status = 'Description cannot be empty.');
setState(() {
_status = 'Description cannot be empty.';
_rejections++;
});
return;
}
final nutrition = nutritionForPortion(
@ -142,17 +145,16 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit meal')),
appBar: AppBar(),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
IconField(
icon: Icons.restaurant,
semanticLabel: 'What did you eat?',
controller: _descController,
decoration: const InputDecoration(
labelText: 'What did you eat?',
),
),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
@ -168,12 +170,20 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
width: double.infinity,
child: ElevatedButton(
onPressed: _onSave,
child: const Text('Save'),
child: const Icon(Icons.check, semanticLabel: 'Save'),
),
),
if (_status != null) ...[
const SizedBox(height: 12),
Text(_status!),
// New key per rejection so a repeat error shakes again.
Align(
alignment: Alignment.centerLeft,
child: StatusCue(
key: ValueKey(_rejections),
kind: CueKind.error,
semanticLabel: _status!,
),
),
],
],
),

View File

@ -153,15 +153,16 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
final all = _allEntries;
return Scaffold(
appBar: AppBar(
title: const Text('Food Bank'),
actions: [
if (all != null)
Stack(
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.filter_list),
tooltip: 'Filter & sort',
icon: const Icon(
Icons.filter_list,
semanticLabel: 'Filter & sort',
),
onPressed: _openFilterSheet,
),
if (_filter.isActive)
@ -185,19 +186,19 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
? const Center(child: CircularProgressIndicator())
: _displayed.isEmpty
? Center(
child: Text(
all.isEmpty
? 'Food bank is empty.\n'
'Log meals to populate it, or add entries manually.'
child: Icon(
all.isEmpty ? Icons.restaurant_menu : Icons.filter_list_off,
size: 64,
color: Theme.of(context).colorScheme.outline,
semanticLabel: all.isEmpty
? 'Food bank is empty.'
: 'No entries match the current filter.',
textAlign: TextAlign.center,
),
)
: FbTileGrid(records: _displayed, onOpen: _openDetail),
floatingActionButton: FloatingActionButton(
onPressed: _openAddDialog,
tooltip: 'Add manual entry',
child: const Icon(Icons.add),
child: const Icon(Icons.add, semanticLabel: 'Add manual entry'),
),
);
}

View File

@ -28,54 +28,27 @@ class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> {
@override
Widget build(BuildContext context) {
// Icon-only: the setup steps (open github.com/settings/developers, make
// an OAuth App with Device Flow, paste its client id) live in the repo
// docs, not on screen.
return AlertDialog(
title: const Text('One-time GitHub setup needed'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Diet Guard signs in via a GitHub OAuth App (no password '
'typed into this app). You only have to set this up once:',
),
const SizedBox(height: 12),
const Text(
'1. On any device, open '
'github.com/settings/developers → "New OAuth App".\n'
'2. Name/Homepage/Callback URL can be anything (device flow '
"doesn't use the callback) — e.g. "
'"Diet Guard" and your GitHub profile URL.\n'
'3. Check "Enable Device Flow", then click "Register '
'application".\n'
"4. Copy the Client ID shown on the app's page and paste it "
'below.',
),
const SizedBox(height: 12),
const Text(
'When you connect below, log in with the GitHub account that '
'has write access to kuhyx/syncs.',
),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Client ID'),
),
],
),
content: IconField(
icon: Icons.apps,
semanticLabel: 'Client ID',
controller: _controller,
autofocus: true,
),
actions: [
TextButton(
IconButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
FilledButton(
IconButton.filled(
onPressed: () {
final id = _controller.text.trim();
if (id.isNotEmpty) Navigator.of(context).pop(id);
},
child: const Text('Continue'),
icon: const Icon(Icons.arrow_forward, semanticLabel: 'Continue'),
),
],
);

View File

@ -21,7 +21,7 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
TextEditingController get _clientIdController;
bool _busy = false;
String? _status;
Widget? _status;
String _storedToken = '';
SyncSettings _currentSettings() {
final typed = _tokenController.text.trim();
@ -37,9 +37,18 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
clientId: _clientIdController.text.trim(),
);
}
void _showMessage(String message) {
/// Shows the outcome as an icon cue; [semanticLabel] is spoken, never
/// painted.
void _showCue(CueKind kind, String semanticLabel) {
if (!mounted) return;
setState(() => _status = message);
setState(
() => _status = StatusCue(
key: UniqueKey(),
kind: kind,
semanticLabel: semanticLabel,
),
);
}
Future<void> _connectGitHub() async {
@ -69,12 +78,12 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
_storedToken = token;
if (SyncSettings.exposesTokenValue) _tokenController.text = token;
});
_showMessage('Connected — syncing…');
_showCue(CueKind.info, 'Connected — syncing…');
await _currentSettings().save();
await _syncAfterConnect();
}
} on Exception catch (e) {
_showMessage('Could not start device flow: $e');
_showCue(CueKind.error, 'Could not start device flow: $e');
} finally {
auth.close();
}
@ -88,10 +97,10 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
try {
await runSync(await syncBackend(client));
await SyncHealth.recordSuccess();
_showMessage('Connected and synced.');
_showCue(CueKind.ok, 'Connected and synced.');
} on Exception catch (e) {
await SyncHealth.recordFailure();
_showMessage('Connected, but sync failed: $e');
_showCue(CueKind.error, 'Connected, but sync failed: $e');
} finally {
client.close();
}
@ -102,7 +111,7 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
await _currentSettings().save();
if (!mounted) return;
setState(() => _busy = false);
_showMessage('Saved.');
_showCue(CueKind.ok, 'Saved.');
}
Future<void> _testConnection() async {
@ -111,11 +120,13 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
final client = createGitHubClient(settings, httpClient: widget.httpClient);
try {
final ok = await client.canAccessRepo();
_showMessage(
ok ? 'GitHub connection OK.' : 'GitHub connection failed.',
);
if (ok) {
_showCue(CueKind.ok, 'GitHub connection OK.');
} else {
_showCue(CueKind.error, 'GitHub connection failed.');
}
} on Exception catch (e) {
_showMessage('GitHub connection failed: $e');
_showCue(CueKind.error, 'GitHub connection failed: $e');
} finally {
client.close();
if (mounted) setState(() => _busy = false);
@ -133,10 +144,10 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
// *because* the banner told them syncing had stopped, so a successful
// run here must dismiss the warning it caused.
await SyncHealth.recordSuccess();
_showMessage('Synced.');
_showCue(CueKind.ok, 'Synced.');
} on Exception catch (e) {
await SyncHealth.recordFailure();
_showMessage('Sync failed: $e');
_showCue(CueKind.error, 'Sync failed: $e');
} finally {
client.close();
if (mounted) setState(() => _busy = false);

View File

@ -13,6 +13,7 @@ import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:diet_guard_app/services/sync_health.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
import 'package:github_device_auth/github_device_auth.dart';
@ -94,107 +95,86 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen>
/// The token as loaded, so a platform that cannot display it (web) still
/// round-trips it instead of blanking it on the next save.
/// Runs the OAuth device flow and, on success, fills in the token field.
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
// Centred and capped at the prose width -- see settings_screen.dart's
// note on why: on the desktop surface an uncapped ListView spanned the
// full window, so explanatory paragraphs ran far past the readable
// line-length limit.
// No title and no explanatory paragraph: the code glyph that opened this
// screen names it, and the connect button is self-evident.
appBar: AppBar(),
// Centred and capped at the prose width so fields do not span the
// whole window on the desktop surface.
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Syncs still mirror to GitHub until every device has moved '
'to Firebase. Authorize in your browser — no token to '
'paste.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
child: IconButton.filled(
onPressed: _connectGitHub,
icon: const Icon(Icons.login),
label: const Text('Connect GitHub'),
icon: const Icon(
Icons.login,
semanticLabel: 'Connect GitHub',
),
),
),
const SizedBox(height: 16),
TextField(
IconField(
icon: Icons.person_outline,
semanticLabel: 'GitHub owner',
controller: _ownerController,
decoration: const InputDecoration(labelText: 'GitHub owner'),
),
const SizedBox(height: 8),
TextField(
IconField(
icon: Icons.folder_outlined,
semanticLabel: 'Repo',
controller: _repoController,
decoration: const InputDecoration(labelText: 'Repo'),
),
const SizedBox(height: 8),
TextField(
IconField(
icon: Icons.apps,
semanticLabel: 'OAuth App client id',
controller: _clientIdController,
decoration: const InputDecoration(
labelText: 'OAuth App client id',
helperText: 'Needed for the Connect GitHub button',
),
),
const SizedBox(height: 8),
TextField(
IconField(
icon: Icons.key,
semanticLabel: 'Personal access token (fallback)',
controller: _tokenController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Personal access token (fallback)',
// Both wrapper hints are web-only; a VM test always takes
// the null branch.
// coverage:ignore-start
helperText: SyncSettings.exposesTokenValue
? null
: _storedToken.isEmpty
? 'Stored by the desktop wrapper, never by the browser'
: 'A token is stored by the desktop wrapper; type '
'here only to replace it',
// coverage:ignore-end
helperMaxLines: 2,
),
obscure: true,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: [
ElevatedButton(
IconButton.filled(
onPressed: _busy ? null : _save,
child: const Text('Save'),
icon: const Icon(Icons.check, semanticLabel: 'Save'),
),
OutlinedButton(
// Tests the GitHub connection only (a canAccessRepo call);
// it says nothing about Firebase.
IconButton.outlined(
onPressed: _busy ? null : _testConnection,
// Named for what it actually does: _testConnection builds
// a GitHub client and calls canAccessRepo(). It says
// nothing about Firebase, and the bare label implied
// otherwise.
child: const Text('Test GitHub connection'),
icon: const Icon(
Icons.network_check,
semanticLabel: 'Test GitHub connection',
),
),
ElevatedButton(
IconButton.filled(
onPressed: _busy ? null : _syncNow,
child: const Text('Sync now'),
icon: const Icon(Icons.sync, semanticLabel: 'Sync now'),
),
],
),
if (_status != null) ...[
const SizedBox(height: 16),
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
Align(alignment: Alignment.centerLeft, child: _status),
],
],
),

View File

@ -159,15 +159,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
final allEntries = _allEntries;
return Scaffold(
appBar: AppBar(
title: const Text('History'),
actions: [
if (allEntries != null)
Stack(
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.filter_list),
tooltip: 'Filter & sort',
icon: const Icon(
Icons.filter_list,
semanticLabel: 'Filter & sort',
),
onPressed: _openFilterSheet,
),
if (_filter.isActive)
@ -191,8 +192,11 @@ class _HistoryScreenState extends State<HistoryScreen> {
? const Center(child: CircularProgressIndicator())
: _displayed.isEmpty
? Center(
child: Text(
allEntries.isEmpty
child: Icon(
allEntries.isEmpty ? Icons.restaurant : Icons.filter_list_off,
size: 64,
color: Theme.of(context).colorScheme.outline,
semanticLabel: allEntries.isEmpty
? 'Nothing logged yet.'
: 'No entries match the current filter.',
),

View File

@ -45,28 +45,23 @@ List<Widget> logMealAppBarActions({
required VoidCallback onBody,
}) => [
IconButton(
icon: const Icon(Icons.monitor_weight),
tooltip: 'Body',
icon: const Icon(Icons.monitor_weight, semanticLabel: 'Body'),
onPressed: onBody,
),
IconButton(
icon: const Icon(Icons.restaurant_menu),
tooltip: 'Food bank',
icon: const Icon(Icons.restaurant_menu, semanticLabel: 'Food bank'),
onPressed: onFoodBank,
),
IconButton(
icon: const Icon(Icons.history),
tooltip: 'History',
icon: const Icon(Icons.history, semanticLabel: 'History'),
onPressed: onHistory,
),
IconButton(
icon: const Icon(Icons.calendar_month),
tooltip: 'Calendar',
icon: const Icon(Icons.calendar_month, semanticLabel: 'Calendar'),
onPressed: onCalendar,
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Sync settings',
icon: const Icon(Icons.settings, semanticLabel: 'Sync settings'),
onPressed: onSettings,
),
];

View File

@ -12,8 +12,23 @@ import 'package:diet_guard_app/screens/log_meal_sync_mixin.dart';
import 'package:diet_guard_app/services/kuchnia_fill_flow.dart';
import 'package:diet_guard_app/services/kuchnia_queue.dart';
import 'package:diet_guard_app/services/kuchnia_spread.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:flutter/material.dart';
/// One fill tap's status (see [CountCue]).
typedef FillCue = CountCue;
/// The cue for a tap's [outcome]: dishes logged (ok + N), a proposal of
/// [armed] dishes (info + N), or nothing done (warn + 0: no delivery, every
/// slot taken, or the fetch failed).
FillCue fillCue(FillOutcome outcome, {required int armed}) {
final written = outcome.written.length;
final label = outcome.message;
if (written > 0) return (label: label, kind: CueKind.ok, count: written);
if (armed > 0) return (label: label, kind: CueKind.info, count: armed);
return (label: label, kind: CueKind.warn, count: 0);
}
/// Drives the screen's [FillAllFlow] and tidies the screen after a fill.
mixin LogMealFillMixin<T extends StatefulWidget>
on LogMealSyncMixin<T>, LogMealKuchniaMixin<T>, LogMealFutureMixin<T> {
@ -21,8 +36,9 @@ mixin LogMealFillMixin<T extends StatefulWidget>
/// armed proposal along with it.
FillAllFlow fillFlow = FillAllFlow();
/// Shows [message] in the form's status line. Supplied by the screen.
void showFillStatus(String message);
/// Shows [cue] as a status cue (its label is only spoken). Supplied by the
/// screen.
void showFillStatus(FillCue cue);
/// A proposal belongs to the date it was fetched for; picking another (or
/// going back to today) must not leave "Confirm (N)" offering it.
@ -49,7 +65,13 @@ mixin LogMealFillMixin<T extends StatefulWidget>
// for the next tick, and the user is told the batch did not finish.
await publishAfterLog();
await refreshSlots();
if (mounted) showFillStatus('Fill all failed: $error');
if (mounted) {
showFillStatus((
label: 'Fill all failed: $error',
kind: CueKind.error,
count: null,
));
}
return;
}
if (!mounted) return;
@ -59,13 +81,17 @@ mixin LogMealFillMixin<T extends StatefulWidget>
await _afterFill(outcome.written, later: day != null);
}
if (!mounted) return;
showFillStatus(outcome.message);
showFillStatus(fillCue(outcome, armed: fillFlow.armedCount));
}
/// Disarms a proposal without writing anything.
void onFillCancel() {
fillFlow.cancel();
showFillStatus('Fill all cancelled — nothing logged.');
showFillStatus((
label: 'Fill all cancelled — nothing logged.',
kind: CueKind.info,
count: null,
));
}
/// Publishes the batch once and drops the filled dishes from the delivery

View File

@ -91,21 +91,34 @@ mixin LogMealFutureMixin<T extends StatefulWidget> on State<T> {
final scheme = Theme.of(context).colorScheme;
return Row(
children: [
Icon(Icons.event, size: 18, color: scheme.onSurfaceVariant),
const SizedBox(width: AppSpacing.xs),
Expanded(child: Text(date == null ? 'Today' : localDateKey(date))),
TextButton.icon(
// Today is the default and shows nothing; only a picked date (data)
// is shown, marked by the calendar glyph.
if (date != null) ...[
Icon(
Icons.event,
size: 18,
color: scheme.onSurfaceVariant,
semanticLabel: 'Log date',
),
const SizedBox(width: AppSpacing.xs),
],
Expanded(
child: date == null ? const SizedBox() : Text(localDateKey(date)),
),
IconButton(
// Not Icons.calendar_month: the app bar's own "Calendar" nav button
// already uses that, and a second one on screen makes
// `find.byIcon` ambiguous for widget tests (and taps) alike.
icon: const Icon(Icons.date_range, size: 18),
label: Text(date == null ? 'Log for later' : 'Change date'),
icon: Icon(
Icons.date_range,
size: 18,
semanticLabel: date == null ? 'Log for later' : 'Change date',
),
onPressed: _pickFutureDate,
),
if (date != null)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Back to today',
icon: const Icon(Icons.close, semanticLabel: 'Back to today'),
onPressed: resetFutureLog,
),
],

View File

@ -25,9 +25,12 @@ import 'package:diet_guard_app/services/kuchnia_queue.dart';
import 'package:diet_guard_app/services/kuchnia_spread.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/cues.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:flutter/material.dart';
CountCue _cue(String l, CueKind k, [int? n]) => (label: l, kind: k, count: n);
/// Writes [dish]'s name and macros into the log form's controllers.
///
/// Free function rather than a mixin method so the numeric formatting is
@ -138,16 +141,20 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
/// not be re-offered) and still records the fetch, so a successful tap
/// also spares the automatic path a second walk.
///
/// Returns a line to show the user, or null when a dish was prefilled and
/// the form speaks for itself.
Future<String?> loadDeliveryOnTap() async {
if (!KuchniaQueueService.isInitialized) return 'Catering is not ready yet.';
/// Returns the cue to show (a spoken line, a glyph kind and a dish count),
/// or null when a dish was prefilled and the form speaks for itself.
Future<CountCue?> loadDeliveryOnTap() async {
if (!KuchniaQueueService.isInitialized) {
return _cue('Catering is not ready yet.', CueKind.warn);
}
final logged = await LogStorageService.instance.todayEntries();
if (!mounted) return null;
final result = await refreshDelivery(DateTime.now());
if (!mounted) return null;
if (!result.ok) return result.reason;
if (result.dishes.isEmpty) return 'No delivery today.';
if (!result.ok) return _cue(result.reason!, CueKind.error);
if (result.dishes.isEmpty) {
return _cue('No delivery today.', CueKind.warn, 0);
}
await KuchniaQueueService.instance.recordFetched(DateTime.now());
if (!mounted) return null;
final ordered = dishesInSlotOrder(
@ -161,7 +168,7 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
},
);
if (KuchniaQueueService.next == null) {
return 'All delivered dishes are already logged.';
return _cue('All delivered dishes are already logged.', CueKind.ok, 0);
}
prefillNextDish();
return null;
@ -179,16 +186,22 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
/// the 250-line cap, and this is the mixin's own behaviour anyway.
Future<void> loadDeliveryAndReport() async {
setState(() => deliveryBusy = true);
String? message;
CountCue? cue;
try {
message = await loadDeliveryOnTap();
cue = await loadDeliveryOnTap();
} finally {
if (mounted) setState(() => deliveryBusy = false);
}
if (!mounted || message == null) return;
ScaffoldMessenger.of(
if (!mounted || cue == null) return;
// The outcome sentence stays as the spoken label; the snackbar paints
// the dish glyph in the kind's tint plus the count (0 = none to log).
showCueSnack(
context,
).showSnackBar(SnackBar(content: Text(message)));
kind: cue.kind,
semanticLabel: cue.label,
icon: Icons.restaurant,
count: cue.count,
);
}
/// Prefills the form with the next queued dish, if there is one.

View File

@ -18,6 +18,7 @@ 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/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
import 'package:diet_guard_app/widgets/fill_all_row.dart';
@ -59,9 +60,14 @@ class _LogMealScreenState extends State<LogMealScreen>
@override
int? selectedSlot;
String _source = 'manual';
String? _status;
CueState? _cue; // painted as a tinted icon, never as text
TodayProgress? _progress;
void _setCue(CueKind kind, String? label, [int? count]) {
_cue = label == null ? null : CueState.next(_cue, kind, label, count);
_progress = null;
}
@override
void initState() {
super.initState();
@ -117,10 +123,8 @@ class _LogMealScreenState extends State<LogMealScreen>
@override
void onDishPrefilled() => setState(() => _source = 'catering');
@override
void showFillStatus(String message) => setState(() {
_status = message;
_progress = null;
});
void showFillStatus(FillCue cue) =>
setState(() => _setCue(cue.kind, cue.label, cue.count));
void _onSuggestionSelected(FoodSuggestion suggestion) {
fillControllersFromSuggestion(suggestion, _descController, _macros);
@ -133,18 +137,12 @@ class _LogMealScreenState extends State<LogMealScreen>
Future<void> _onLogMeal() async {
final desc = _descController.text.trim();
if (desc.isEmpty) {
setState(() {
_status = 'Type what you ate first.';
_progress = null;
});
setState(() => _setCue(CueKind.error, 'Type what you ate first.'));
return;
}
final target = resolveLogTarget(selectedSlot);
if (target.error != null) {
setState(() {
_status = target.error;
_progress = null;
});
setState(() => _setCue(CueKind.error, target.error));
return;
}
// On the tap, before the write/sync: waiting for them is just latency.
@ -171,7 +169,7 @@ class _LogMealScreenState extends State<LogMealScreen>
// becomes a dead letter -- the exact regression the PC gate once shipped.
advanceQueueAfterLog(desc);
setState(() {
_status = queueStatusLine;
_setCue(CueKind.info, queueStatusLine);
_progress = buildTodayProgress(log, logged: entry);
});
}
@ -180,7 +178,8 @@ class _LogMealScreenState extends State<LogMealScreen>
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Diet Guard'),
// No title: the app's identity is the launcher icon, and the five
// destination glyphs fill the bar.
actions: [
...logMealAppBarActions(
onFoodBank: onOpenFoodBank,
@ -206,9 +205,10 @@ class _LogMealScreenState extends State<LogMealScreen>
setState(() => selectedSlot = slot),
),
const SizedBox(height: 8),
TextField(
IconField(
controller: _descController,
decoration: const InputDecoration(labelText: 'What did you eat?'),
icon: Icons.fastfood,
semanticLabel: 'What did you eat?',
),
AutocompleteSuggestionList(
suggestions: _suggestions,
@ -231,12 +231,12 @@ class _LogMealScreenState extends State<LogMealScreen>
busy: fillFlow.busy || deliveryBusy,
armedCount: fillFlow.armedCount,
),
// Mutually exclusive: _status carries a validation complaint,
// _progress the post-log summary. A successful log clears one
// and sets the other, so the two never stack.
if (_status != null) ...[
// Mutually exclusive: _cue carries a validation complaint or
// queue note, _progress the post-log summary; the two may stack
// only after a log that leaves dishes queued.
if (_cue != null) ...[
const SizedBox(height: AppSpacing.sm),
Text(_status!),
_cue!.widget,
],
if (_progress != null) ...[
const SizedBox(height: AppSpacing.sm),

View File

@ -7,11 +7,18 @@
part of 'settings_screen.dart';
mixin _SettingsKcalGoal on State<SettingsScreen> {
String? _status;
/// The latest outcome as an icon cue; null until something happened.
Widget? _status;
void _showMessage(String message) {
void _showCue(CueKind kind, String semanticLabel) {
if (!mounted) return;
setState(() => _status = message);
setState(
() => _status = StatusCue(
key: UniqueKey(),
kind: kind,
semanticLabel: semanticLabel,
),
);
}
Timer? _kcalGoalDebounce;
@ -50,13 +57,13 @@ mixin _SettingsKcalGoal on State<SettingsScreen> {
() => Permission.ignoreBatteryOptimizations.request();
try {
final status = await request();
_showMessage(
status.isGranted
? 'Battery optimization exemption granted.'
: 'Exemption not granted -- notifications may be delayed.',
);
if (status.isGranted) {
_showCue(CueKind.ok, 'Battery optimization exemption granted.');
} else {
_showCue(CueKind.warn, 'Exemption not granted.');
}
} on Exception catch (e) {
_showMessage('Could not request exemption: $e');
_showCue(CueKind.error, 'Could not request exemption: $e');
}
}
}

View File

@ -18,21 +18,39 @@ library;
import 'package:diet_guard_app/services/kuchnia_client.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:diet_guard_app/services/kuchnia_import.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// What a delivery fetch reports: a cue [kind], a spoken [label] (never
/// painted) and, when dishes were banked, their [count] (painted as a number).
class FetchOutcome {
/// Creates an outcome.
const FetchOutcome(this.kind, this.label, {this.count});
/// Icon/colour of the cue.
final CueKind kind;
/// Spoken description; never painted.
final String label;
/// Dishes banked, shown as a bare number next to the cue.
final int? count;
}
/// Fetches today's delivery and reports what was banked.
///
/// Deliberately says "banked", not "logged": a delivered meal is not an eaten
/// Deliberately "banked", not "logged": a delivered meal is not an eaten
/// meal, so the dishes land in the food bank and the user still taps to log
/// each one. Top-level so the settings screen can pass it without growing.
Future<String> fetchTodaysDelivery() async {
Future<FetchOutcome> fetchTodaysDelivery() async {
final result = await refreshDelivery(DateTime.now());
if (!result.ok) return result.reason!;
if (result.dishes.isEmpty) return 'No delivery found for today.';
if (!result.ok) return FetchOutcome(CueKind.error, result.reason!);
if (result.dishes.isEmpty) {
return const FetchOutcome(CueKind.warn, 'No delivery found for today.');
}
final count = result.dishes.length;
return '$count ${count == 1 ? 'dish' : 'dishes'} added to your food bank. '
'Log them from the meal screen when you eat them.';
return FetchOutcome(CueKind.ok, '$count dishes added', count: count);
}
/// Credential fields plus, on Android, a "fetch today's delivery" action.
@ -43,18 +61,14 @@ class KuchniaSettingsSection extends StatefulWidget {
/// button is replaced by a one-line explanation rather than silently
/// vanishing. [onFetch] defaults to the real fetch. Both are injectable so a
/// widget test can drive the section without a network or a platform check.
const KuchniaSettingsSection({
this.canFetch,
this.onFetch,
super.key,
});
const KuchniaSettingsSection({this.canFetch, this.onFetch, super.key});
/// Whether this platform can reach the catering panel; null means ask.
final bool? canFetch;
/// Fetches today's delivery and returns a status line; null means the real
/// one.
final Future<String> Function()? onFetch;
final Future<FetchOutcome> Function()? onFetch;
@override
State<KuchniaSettingsSection> createState() => _KuchniaSettingsSectionState();
@ -69,7 +83,7 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
);
bool _obscured = true;
bool _busy = false;
String? _status;
Widget? _status;
@override
void dispose() {
@ -85,7 +99,13 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
_password.text,
);
if (!mounted) return;
setState(() => _status = 'Saved. It will sync to your other devices.');
setState(
() => _status = StatusCue(
key: UniqueKey(),
kind: CueKind.ok,
semanticLabel: 'Saved. It will sync to your other devices.',
),
);
}
Future<void> _fetch() async {
@ -93,62 +113,65 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
_busy = true;
_status = null;
});
final message = await (widget.onFetch ?? fetchTodaysDelivery)();
final outcome = await (widget.onFetch ?? fetchTodaysDelivery)();
if (!mounted) return;
setState(() {
_busy = false;
_status = message;
_status = Row(
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.sm,
children: [
StatusCue(
key: UniqueKey(),
kind: outcome.kind,
semanticLabel: outcome.label,
),
if (outcome.count != null) Text('${outcome.count}'),
],
);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final canFetch = widget.canFetch ?? kuchniaFetchSupported;
// The section owns its own leading divider so the settings screen needs a
// single line for it -- that file sits on the repo's 250-line ceiling.
// No heading or blurb: the delivery truck on the fetch button names it.
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Text('Kuchnia Wikinga', style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
"Your catering panel login, so this device can look up the day's "
'delivered dishes and their macros. Stored and synced in plain '
'text, like the rest of your synced data.',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppWidth.field),
child: TextField(
child: IconField(
icon: Icons.alternate_email,
semanticLabel: 'Panel e-mail',
controller: _username,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
decoration: const InputDecoration(labelText: 'Panel e-mail'),
onSubmitted: (_) => _save(),
),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppWidth.field),
child: TextField(
child: IconField(
icon: Icons.key,
semanticLabel: 'Panel password',
controller: _password,
obscureText: _obscured,
obscure: _obscured,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: 'Panel password',
suffixIcon: IconButton(
icon: Icon(_obscured ? Icons.visibility : Icons.visibility_off),
tooltip: _obscured ? 'Show password' : 'Hide password',
onPressed: () => setState(() => _obscured = !_obscured),
),
),
onSubmitted: (_) => _save(),
suffix: IconButton(
icon: Icon(
_obscured ? Icons.visibility : Icons.visibility_off,
semanticLabel: _obscured ? 'Show password' : 'Hide password',
),
onPressed: () => setState(() => _obscured = !_obscured),
),
),
),
const SizedBox(height: 12),
@ -156,29 +179,27 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
spacing: 8,
runSpacing: 8,
children: [
FilledButton(onPressed: _save, child: const Text('Save login')),
if (canFetch)
OutlinedButton.icon(
onPressed: _busy ? null : _fetch,
icon: const Icon(Icons.restaurant),
label: Text(
_busy ? 'Fetching…' : "Fetch today's delivery",
),
),
IconButton.filled(
onPressed: _save,
icon: const Icon(Icons.check, semanticLabel: 'Save login'),
),
// Browser builds show the button greyed out: the panel blocks
// browser requests, and the disabled state is the cue.
IconButton.outlined(
onPressed: canFetch && !_busy ? _fetch : null,
icon: _busy
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(
Icons.local_shipping_outlined,
semanticLabel: "Fetch today's delivery",
),
),
],
),
if (!canFetch) ...[
const SizedBox(height: 8),
Text(
'Android only — the catering panel blocks browser requests. Your '
'login still syncs to your phone from here.',
style: theme.textTheme.bodySmall,
),
],
if (_status != null) ...[
const SizedBox(height: 8),
Text(_status!, style: theme.textTheme.bodyMedium),
],
if (_status != null) ...[const SizedBox(height: 8), _status!],
],
);
}

View File

@ -15,6 +15,8 @@ import 'package:diet_guard_app/services/firebase_backend.dart';
import 'package:diet_guard_app/services/firebase_client.dart';
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/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.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';
@ -149,30 +151,26 @@ class _SettingsScreenState extends State<SettingsScreen>
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
// Capped at the prose width: on the ~1366px desktop window the
// paragraphs below ran ~180 chars/line, twice tokens.md rule 21's limit.
// No title: the gear that opened this screen already names it.
appBar: AppBar(),
// Capped at the prose width so fields do not span a ~1366px window.
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Nutrition', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
// A four-digit number: capped so the field does not read as a
// layout error by spanning the whole column.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppWidth.field),
child: TextField(
child: IconField(
icon: MacroKind.kcal.icon,
iconColor: MacroKind.kcal.color,
semanticLabel: 'Daily kcal goal',
controller: _kcalGoalController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Daily kcal goal',
helperText: 'Shown in the history day summary',
suffixText: 'kcal',
),
onChanged: _onKcalGoalChanged,
),
),
@ -186,15 +184,21 @@ class _SettingsScreenState extends State<SettingsScreen>
const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Sync settings'),
subtitle: const Text('Firebase sync'),
leading: const Icon(
Icons.cloud_sync_outlined,
semanticLabel: 'Sync settings',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openSyncSettings()),
),
// Legacy mirror: muted tint marks it as the secondary path.
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Advanced sync (GitHub)'),
subtitle: const Text('Cutover mirror — not recommended'),
leading: Icon(
Icons.code,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Advanced sync (GitHub)',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openGitHubMirror()),
),
@ -203,42 +207,27 @@ class _SettingsScreenState extends State<SettingsScreen>
.dailyKcalGoal
.toString(),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Text(
'Notifications',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
_isAndroid
? 'A background check nags you every ~15 min if a '
'meal slot is overdue. Aggressive OEM battery '
'optimization (MIUI, some Samsung configs) can '
'delay this well past 15 min -- request an '
'exemption for reliable nagging.'
: 'A check runs every 5 min while this window is open and '
'notifies you about an overdue meal slot. A browser '
'cannot run anything once the window is closed -- on '
'the PC the real backstop is the diet_guard gate, '
'which locks the screen instead.',
style: Theme.of(context).textTheme.bodySmall,
),
// Battery-optimization exemption is Android-only, and
// `permission_handler` has no web implementation at all;
// the row would throw rather than degrade in the browser.
if (_isAndroid) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _requestBatteryExemption,
icon: const Icon(Icons.battery_alert),
label: const Text('Disable battery optimization'),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: IconButton.outlined(
onPressed: _requestBatteryExemption,
icon: const Icon(
Icons.battery_alert,
semanticLabel: 'Disable battery optimization',
),
),
),
],
if (_status != null) ...[
const SizedBox(height: 16),
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
Align(alignment: Alignment.centerLeft, child: _status),
],
],
),

View File

@ -22,13 +22,6 @@ export 'package:diet_guard_app/models/workout_session.dart';
/// Formula names, in display order.
const List<String> activityFormulas = ['MET', 'ACSM', 'Per-km'];
/// The methods explained, shown under the Activity card.
const String methodsNote =
'MET = standard activity values x your weight x time. '
'ACSM = American College of Sports Medicine running/walking equation '
'from your speed. Per-km = ~0.9 kcal per kg per km running, ~0.5 '
'walking. All are energy above resting.';
/// Measured activity averages at most the last two full weeks.
const int maxWindowDays = 14;

View File

@ -31,16 +31,6 @@ const List<(double, String)> _bmiBands = [
];
const String _topBand = 'obese class III';
/// BMI Prime explained, shown under the numbers on both surfaces.
const String bmiPrimeNote =
'BMI Prime = BMI / 25: 1.00 is the top of the healthy range, '
'so 1.02 means 2% above it.';
/// Trefethen's BMI explained, shown under the numbers on both surfaces.
const String trefethenNote =
"Trefethen's new BMI = 1.3 x kg / m^2.5: corrects classic BMI, which "
'over-reads tall people and under-reads short ones.';
/// The lean-mass formula the calorie table switches to with a body fat %.
const String katchMcArdle = 'Katch-McArdle';

View File

@ -20,8 +20,9 @@ abstract class NotificationBackend {
/// of the app.
Future<bool?> requestPermission();
/// Posts (or replaces) the notification for [slot].
Future<void> show(int slot, String title, String body);
/// Posts (or replaces) the notification for [slot], titled with the slot's
/// clock time and carrying no body text.
Future<void> show(int slot, String title);
/// Removes the notification for [slot], if one is showing.
Future<void> cancel(int slot);

View File

@ -15,7 +15,8 @@ class LocalNotificationsBackend implements NotificationBackend {
final FlutterLocalNotificationsPlugin plugin;
static const _channelId = 'diet_guard_due_slot';
static const _channelName = 'Meal reminders';
// The app's proper name, same as the launcher label (no descriptive text).
static const _channelName = 'DietGuard';
@override
Future<void> initialize() async {
@ -23,6 +24,20 @@ class LocalNotificationsBackend implements NotificationBackend {
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
);
await plugin.initialize(settings: settings);
// Re-create the channel so Android renames it if an install still shows
// the old "Meal reminders" label.
await plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(
const AndroidNotificationChannel(
_channelId,
_channelName,
importance: Importance.high,
),
);
}
/// Requests Android 13+'s runtime `POST_NOTIFICATIONS` permission.
@ -47,7 +62,7 @@ class LocalNotificationsBackend implements NotificationBackend {
Future.value();
@override
Future<void> show(int slot, String title, String body) async {
Future<void> show(int slot, String title) async {
const details = NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
@ -56,12 +71,7 @@ class LocalNotificationsBackend implements NotificationBackend {
priority: Priority.high,
),
);
await plugin.show(
id: slot,
title: title,
body: body,
notificationDetails: details,
);
await plugin.show(id: slot, title: title, notificationDetails: details);
}
@override

View File

@ -36,13 +36,13 @@ class WebNotificationBackend implements NotificationBackend {
}
@override
Future<void> show(int slot, String title, String body) async {
Future<void> show(int slot, String title) async {
// Posting without permission throws; a silent no-op matches this
// service's degrade-quietly stance everywhere else.
if (!_granted) return;
_shown[slot] = web.Notification(
title,
web.NotificationOptions(body: body, tag: 'diet_guard_slot_$slot'),
web.NotificationOptions(tag: 'diet_guard_slot_$slot'),
);
}

View File

@ -86,11 +86,9 @@ class NotificationService {
await _backend.cancel(id);
}
for (final slot in due) {
await _backend.show(
slot,
'Meal not logged',
"You haven't logged your ${slotLabel(slot)} meal yet.",
);
// The title is only the overdue slot's clock time (a number from the
// user's own schedule); the launcher icon is the second channel.
await _backend.show(slot, slotLabel(slot));
}
}
}

View File

@ -53,8 +53,7 @@ abstract final class SyncHealth {
_recordFailure(SyncFailureKind.unconfigured);
/// Marks a tick that had a backend but failed to publish.
static Future<void> recordFailure() =>
_recordFailure(SyncFailureKind.failed);
static Future<void> recordFailure() => _recordFailure(SyncFailureKind.failed);
static Future<void> _recordFailure(SyncFailureKind kind) =>
_withPrefs((prefs) => prefs.setString(_kFailureKind, kind.name));
@ -155,6 +154,16 @@ class SyncHealthStatus {
return 'Not syncing since ${_ago(since)}. Meals stay on this device.';
}
/// Compact elapsed time (`5h`, `2d`) for a numeral beside the banner's
/// glyph; null when it has not been at least an hour or never succeeded.
String? get agoShort {
final last = lastSuccess;
if (last == null) return null;
final since = now.difference(last);
if (since < const Duration(hours: 1)) return null;
return since.inDays >= 1 ? '${since.inDays}d' : '${since.inHours}h';
}
static String _ago(Duration since) {
if (since.inDays >= 1) {
return '${since.inDays}d ago';

246
app/lib/ui/cues.dart Normal file
View File

@ -0,0 +1,246 @@
/// Text-free status cues: tinted icon + shake + haptic instead of sentences.
///
/// Every cue carries its meaning twice (icon shape AND colour) and exposes
/// the old sentence only as a screen-reader label, never as visible text.
library;
import 'dart:async';
import 'dart:math' as math;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
export 'package:diet_guard_app/ui/icon_field.dart';
/// What a [StatusCue] reports.
enum CueKind {
/// Something worked.
ok(Icons.check_circle),
/// Neutral information.
info(Icons.info),
/// Caution, e.g. nothing to do or only partly done.
warn(Icons.warning_amber_rounded),
/// Something failed or the input was rejected.
error(Icons.error);
const CueKind(this.icon);
/// Default glyph for this kind.
final IconData icon;
/// Tint for this kind from the active theme.
Color tint(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final status =
Theme.of(context).extension<AppStatusColors>() ?? AppStatusColors.dark;
return switch (this) {
CueKind.ok => status.success,
CueKind.info => scheme.onSurfaceVariant,
CueKind.warn => status.warning,
CueKind.error => scheme.error,
};
}
}
/// A tinted icon chip that replaces a status sentence.
///
/// [CueKind.error] shakes (~3 swings, skipped when the platform asks for
/// reduced motion) and fires a heavy haptic; [CueKind.ok] fires a light one.
/// [semanticLabel] is announced as a live region and is never painted.
class StatusCue extends StatefulWidget {
/// Creates a cue of [kind]; [icon] overrides the kind's default glyph.
const StatusCue({
required this.kind,
required this.semanticLabel,
this.icon,
this.count,
super.key,
});
/// Meaning of the cue.
final CueKind kind;
/// Spoken description (screen readers, tests); never painted.
final String semanticLabel;
/// Optional glyph replacing [CueKind.icon].
final IconData? icon;
/// Optional numeral painted beside the glyph (e.g. dishes logged).
final int? count;
@override
State<StatusCue> createState() => _StatusCueState();
}
class _StatusCueState extends State<StatusCue>
with SingleTickerProviderStateMixin {
late final AnimationController _shake = AnimationController(
vsync: this,
duration: AppDuration.slow * 2,
);
@override
void initState() {
super.initState();
unawaited(_announce());
}
Future<void> _announce() async {
switch (widget.kind) {
case CueKind.error:
await HapticFeedback.heavyImpact();
if (mounted && !MediaQuery.disableAnimationsOf(context)) {
await _shake.forward();
}
case CueKind.ok:
await HapticFeedback.lightImpact();
case CueKind.info || CueKind.warn:
break;
}
}
@override
void dispose() {
_shake.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tint = widget.kind.tint(context);
return Semantics(
liveRegion: true,
label: widget.semanticLabel,
child: ExcludeSemantics(
child: AnimatedBuilder(
animation: _shake,
builder: (context, child) => Transform.translate(
offset: Offset(math.sin(_shake.value * 6 * math.pi) * 8, 0),
child: child,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: tint),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: _GlyphCount(
widget.icon ?? widget.kind.icon,
tint,
widget.count,
),
),
),
),
),
);
}
}
/// Shows an icon-only snackbar; [onUndo] adds an undo arrow inside it.
///
/// `SnackBarAction` can only carry text, so the undo is an [IconButton].
void showCueSnack(
BuildContext context, {
required CueKind kind,
required String semanticLabel,
IconData? icon,
int? count,
VoidCallback? onUndo,
}) {
final messenger = ScaffoldMessenger.of(context)..hideCurrentSnackBar();
final tint = kind.tint(context);
messenger.showSnackBar(
SnackBar(
content: Semantics(
liveRegion: true,
label: semanticLabel,
child: Row(
children: [
ExcludeSemantics(
child: _GlyphCount(icon ?? kind.icon, tint, count),
),
const Spacer(),
if (onUndo != null)
IconButton(
icon: const Icon(Icons.undo, semanticLabel: 'Undo'),
onPressed: () {
messenger.hideCurrentSnackBar();
onUndo();
},
),
],
),
),
),
);
}
/// A status to show: `label` is only spoken; the `kind` glyph and the
/// `count` numeral (null = glyph only) are what is painted.
typedef CountCue = ({String label, CueKind kind, int? count});
/// One screen's current status cue; [seq] re-keys the widget so an identical
/// consecutive status still shakes/pulses again.
@immutable
class CueState {
/// Creates the successor of [previous] carrying [kind] and [label].
CueState.next(CueState? previous, this.kind, this.label, [this.count])
: seq = (previous?.seq ?? 0) + 1;
/// Meaning.
final CueKind kind;
/// Spoken description; never painted.
final String label;
/// Optional numeral painted beside the glyph.
final int? count;
/// Monotonic counter used as the widget key.
final int seq;
/// The left-aligned cue widget for this state.
Widget get widget => Align(
alignment: Alignment.centerLeft,
child: StatusCue(
key: ValueKey(seq),
kind: kind,
semanticLabel: label,
count: count,
),
);
}
/// A tinted glyph, followed by [count] when there is one.
class _GlyphCount extends StatelessWidget {
const _GlyphCount(this.icon, this.tint, this.count);
final IconData icon;
final Color tint;
final int? count;
@override
Widget build(BuildContext context) {
final n = count;
if (n == null) return Icon(icon, color: tint);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: tint),
const SizedBox(width: AppSpacing.xs),
Text(
'$n',
style: TextStyle(color: tint, fontWeight: FontWeight.w700),
),
],
);
}
}

129
app/lib/ui/icon_field.dart Normal file
View File

@ -0,0 +1,129 @@
/// An icon-captioned text field: the glyph is painted, the words are only a
/// spoken label (see [IconField]).
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A bordered text field whose caption is an icon plus a spoken label.
///
/// Replaces `InputDecoration(labelText: ...)`: the glyph is painted, the
/// words exist only for screen readers, tests and flutter-web-drive.
class IconField extends StatelessWidget {
/// Creates a field captioned by [icon].
const IconField({
required this.icon,
required this.semanticLabel,
this.controller,
this.keyboardType,
this.obscure = false,
this.suffix,
this.onChanged,
this.dense = false,
this.fieldKey,
this.iconColor,
this.inputFormatters,
this.onSubmitted,
this.autofocus = false,
this.autocorrect = true,
super.key,
});
/// Input formatters, e.g. digits only.
final List<TextInputFormatter>? inputFormatters;
/// Called when the user submits from the keyboard.
final ValueChanged<String>? onSubmitted;
/// Whether the field grabs focus when it first appears.
final bool autofocus;
/// Whether the keyboard may autocorrect (false for e-mails and secrets).
final bool autocorrect;
/// Painted caption.
final IconData icon;
/// Tint of [icon]; the theme's default when null.
final Color? iconColor;
/// Spoken caption; never painted.
final String semanticLabel;
/// Backing controller.
final TextEditingController? controller;
/// Keyboard type.
final TextInputType? keyboardType;
/// Hides the typed characters.
final bool obscure;
/// Trailing widget, e.g. a visibility toggle.
final Widget? suffix;
/// Change callback.
final ValueChanged<String>? onChanged;
/// Compact vertical padding.
final bool dense;
/// Key given to the inner [TextField].
final Key? fieldKey;
@override
Widget build(BuildContext context) {
// MergeSemantics folds the spoken label into the TextField's own node:
// a bare `Semantics(textField: true)` wrapper produced a second,
// disabled textbox beside the real one, so drivers tapped the dead one.
return MergeSemantics(
child: Semantics(
label: semanticLabel,
child: TextField(
key: fieldKey,
controller: controller,
keyboardType: keyboardType,
obscureText: obscure,
onChanged: onChanged,
onSubmitted: onSubmitted,
inputFormatters: inputFormatters,
autofocus: autofocus,
autocorrect: autocorrect,
enableSuggestions: autocorrect,
decoration: InputDecoration(
prefixIcon: ExcludeSemantics(child: Icon(icon, color: iconColor)),
prefixIconConstraints: dense
? const BoxConstraints(minWidth: 32, minHeight: 32)
: null,
suffixIcon: suffix,
isDense: dense,
),
),
),
);
}
}
/// Gives [child] a spoken name without painting any text.
///
/// Wraps `Semantics(label:)`; keeping the string on a `semanticLabel:` line
/// lets line-based text scanners recognise it as accessibility metadata even
/// after the formatter splits `Semantics(` from its arguments.
class SpokenLabel extends StatelessWidget {
/// Creates a labelled wrapper around [child].
const SpokenLabel({
required this.semanticLabel,
required this.child,
super.key,
});
/// Spoken name; never painted.
final String semanticLabel;
/// The widget being named.
final Widget child;
@override
Widget build(BuildContext context) =>
Semantics(label: semanticLabel, container: true, child: child);
}

View File

@ -0,0 +1,80 @@
/// Shape + colour vocabulary for the nutrition fields, so a field or figure
/// is recognised by its glyph instead of a "P"/"C"/"F"/"kcal" word.
///
/// Protein is a green circle, carbs an amber triangle, fat a blue square;
/// colour never carries the meaning alone (shape does too).
library;
import 'package:flutter/material.dart';
/// One nutrition field and its non-text identity.
enum MacroKind {
/// Reference weight the typed macros are stated for.
perGrams(Icons.scale, Color(0xFFAAA09A), 'per (g)', 'per'),
/// Energy.
kcal(Icons.bolt, Color(0xFFE08A3C), 'kcal', 'kcal'),
/// Protein.
protein(Icons.circle, Color(0xFF8A9A3C), 'protein g', 'P'),
/// Carbohydrate.
carbs(Icons.change_history, Color(0xFFE0A63C), 'carbs g', 'C'),
/// Fat.
fat(Icons.square, Color(0xFF6C9BD1), 'fat g', 'F'),
/// Portion weight actually eaten.
eaten(Icons.rice_bowl, Color(0xFFAAA09A), 'eaten (g)', 'eaten');
const MacroKind(this.icon, this.color, this._long, this._short);
/// Painted glyph.
final IconData icon;
/// Glyph tint.
final Color color;
final String _long;
final String _short;
/// Spoken name; the long form unless [compact].
String semanticLabel({required bool compact}) => compact ? _short : _long;
}
/// A glyph + number pair, e.g. a green circle and `12`.
class MacroFigure extends StatelessWidget {
/// Creates a figure for [kind] showing [grams] (or [value] verbatim).
const MacroFigure({
required this.kind,
required this.value,
this.size = 12,
super.key,
});
/// Which nutrient.
final MacroKind kind;
/// The number to show, already formatted.
final String value;
/// Glyph size.
final double size;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
kind.icon,
color: kind.color,
size: size,
semanticLabel: kind.semanticLabel(compact: false),
),
const SizedBox(width: 3),
Text(value),
],
);
}
}

View File

@ -0,0 +1,33 @@
/// Glyph for where a log entry came from, replacing the stored source word
/// (`manual`, `food bank`, `catering`) that used to be printed on screen.
library;
import 'package:flutter/material.dart';
/// Icon for a stored entry `source` value; unknown values get a neutral dot.
IconData sourceIcon(String source) => switch (source) {
'manual' => Icons.edit,
'food bank' => Icons.restaurant_menu,
'catering' => Icons.local_shipping,
_ => Icons.circle_outlined,
};
/// A small painted [sourceIcon] that still speaks the stored source value.
class SourceMark extends StatelessWidget {
/// Creates a mark for [source].
const SourceMark(this.source, {this.size = 14, super.key});
/// Stored source value.
final String source;
/// Glyph size.
final double size;
@override
Widget build(BuildContext context) => Icon(
sourceIcon(source),
size: size,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: source,
);
}

View File

@ -2,6 +2,7 @@
library;
import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:flutter/material.dart';
/// A tappable list of [FoodSuggestion]s, each filling the form on tap.
@ -57,8 +58,12 @@ class AutocompleteSuggestionList extends StatelessWidget {
return ListTile(
dense: true,
title: Text(suggestion.name),
subtitle: Text(
'${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal',
subtitle: Align(
alignment: Alignment.centerLeft,
child: MacroFigure(
kind: MacroKind.kcal,
value: suggestion.nutrition.kcal.toStringAsFixed(0),
),
),
onTap: () => onSelected(suggestion),
);
@ -76,17 +81,34 @@ class AutocompleteSuggestionList extends StatelessWidget {
onTap: () => onSelected(suggestion),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'${suggestion.name} · '
'${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal',
overflow: TextOverflow.ellipsis,
child: Row(
children: [
Flexible(
child: Text(
suggestion.name,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
MacroFigure(
kind: MacroKind.kcal,
value: suggestion.nutrition.kcal.toStringAsFixed(0),
),
],
),
),
),
if (remaining > 0)
TextButton(
onPressed: () => _showMore(context),
child: Text('$remaining more'),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.more_horiz, semanticLabel: 'More'),
const SizedBox(width: 4),
Text('+$remaining'),
],
),
),
],
);

View File

@ -2,23 +2,23 @@
library;
import 'package:diet_guard_app/models/period_average.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:flutter/material.dart';
/// Displays this week's and this month's average kcal/day and their bands.
///
/// Mirrors `_calendar_view.py`'s `averages_text`: "Avg/day (to yesterday):
/// week NNNN kcal (band) · month NNNN kcal (band)".
/// Mirrors `_calendar_view.py`'s `averages_text`, minus the words: each period
/// is a calendar glyph, the average as a number, and the band as a tinted
/// icon (check = under, warning = slightly over, error = very over, dash =
/// no data) so colour is never the only channel.
///
/// Both periods stop at yesterday, so this line does not swing every time a
/// meal is logged today -- it answers "how has this week gone", not "how am I
/// doing right now", which is what the Today progress card is for.
class AverageSummaryRow extends StatelessWidget {
/// Creates an [AverageSummaryRow].
const AverageSummaryRow({
required this.week,
required this.month,
super.key,
});
const AverageSummaryRow({required this.week, required this.month, super.key});
/// The current ISO week's average, through yesterday.
final PeriodAverage week;
@ -26,18 +26,72 @@ class AverageSummaryRow extends StatelessWidget {
/// The current calendar month's average, through yesterday.
final PeriodAverage month;
static String _part(PeriodAverage period) {
final avg = period.avgKcal;
if (avg == null) return bandLabel(period.band);
return '${avg.round()} kcal (${bandLabel(period.band)})';
}
@override
Widget build(BuildContext context) {
return Text(
'Avg/day (to yesterday): week ${_part(week)} · month ${_part(month)}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PeriodFigure(
period: week,
icon: Icons.calendar_view_week,
semanticLabel: 'week',
),
const SizedBox(width: 24),
_PeriodFigure(
period: month,
icon: Icons.calendar_view_month,
semanticLabel: 'month',
),
],
);
}
}
class _PeriodFigure extends StatelessWidget {
const _PeriodFigure({
required this.period,
required this.icon,
required this.semanticLabel,
});
final PeriodAverage period;
final IconData icon;
final String semanticLabel;
(CueKind, IconData) get _band => switch (period.band) {
null => (CueKind.info, Icons.remove_circle_outline),
AverageBand.under => (CueKind.ok, Icons.check_circle),
AverageBand.slightlyOver => (CueKind.warn, Icons.warning_amber_rounded),
AverageBand.veryOver => (CueKind.error, Icons.error),
};
@override
Widget build(BuildContext context) {
final (kind, bandIcon) = _band;
final avg = period.avgKcal;
final style = Theme.of(context).textTheme.bodySmall;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, semanticLabel: semanticLabel),
const SizedBox(width: 4),
Icon(
bandIcon,
size: 16,
color: kind.tint(context),
semanticLabel: bandLabel(period.band),
),
if (avg != null) ...[
const SizedBox(width: 4),
Icon(
MacroKind.kcal.icon,
size: 14,
color: MacroKind.kcal.color,
semanticLabel: 'kcal',
),
Text('${avg.round()}', style: style),
],
],
);
}
}

View File

@ -2,8 +2,12 @@
library;
import 'package:diet_guard_app/services/activity_kcal.dart';
import 'package:diet_guard_app/services/body_steps.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:diet_guard_app/widgets/body/formula_figure.dart';
import 'package:diet_guard_app/widgets/body/workout_kind_icon.dart';
import 'package:flutter/material.dart';
/// How many of the newest sessions to list.
@ -55,48 +59,118 @@ class ActivityCard extends StatelessWidget {
final lastDay = steps.isEmpty
? null
: steps.keys.reduce((a, b) => a.compareTo(b) > 0 ? a : b);
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return BodySection(
title: 'Activity',
semanticLabel: 'Activity',
children: [
if (sessions == null && steps.isEmpty)
const BodyNote('No activity data yet -- published by the PC.'),
const StatusCue(
kind: CueKind.info,
icon: Icons.directions_run,
semanticLabel: 'No activity data yet -- published by the PC.',
),
if (means != null) ...[
BodyNote('Mean daily exercise, last $window full days:'),
Row(
children: [
Icon(
Icons.date_range,
size: 18,
color: muted,
semanticLabel: 'Mean daily exercise, last $window full days:',
),
const SizedBox(width: AppSpacing.xs),
Text('$window'),
],
),
for (final name in activityFormulas)
BodyStatRow(name, '${whole(means[name] ?? 0)} kcal/day'),
_FormulaRow(name, whole(means[name] ?? 0)),
],
if (lastDay != null && stepKcal != null)
BodyNote(
'Steps outside workouts: ${steps[lastDay]} on $lastDay; above '
'$stepBaseline/day they add ${whole(stepKcal)} kcal/day '
'(included above).',
Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
children: [
Icon(
Icons.directions_walk,
size: 18,
color: muted,
semanticLabel: 'Steps outside workouts',
),
const SizedBox(width: AppSpacing.xs),
Text('${steps[lastDay]} $lastDay'),
const Spacer(),
Icon(Icons.add, size: 16, color: muted),
MacroFigure(
kind: MacroKind.kcal,
value: whole(stepKcal),
size: 16,
),
],
),
),
if (onConnectSteps != null)
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
icon: const Icon(Icons.directions_walk),
label: const Text('Connect Health Connect steps'),
child: OutlinedButton(
onPressed: onConnectSteps,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.directions_walk,
semanticLabel: 'Connect Health Connect steps',
),
Icon(Icons.link),
],
),
),
),
if (sessions != null && kg == null)
const BodyNote('Enter your weight to estimate exercise kcal.'),
const StatusCue(
kind: CueKind.warn,
icon: Icons.monitor_weight,
semanticLabel: 'Enter your weight to estimate exercise kcal.',
),
if (sessions != null && kg != null) ...[
const Divider(),
if (newest.isEmpty)
const BodyNote('No workouts in the last 28 days.'),
const StatusCue(
kind: CueKind.info,
icon: Icons.event_busy,
semanticLabel: 'No workouts in the last 28 days.',
),
for (final session in newest.take(activityListLimit))
_SessionTile(result: sessionKcal(session, kg)),
if (newest.isNotEmpty)
const BodyNote('~ = no distance, MET estimate used instead.'),
const BodyNote(methodsNote),
],
],
);
}
}
/// A formula's glyph on the left, its kcal figure on the right.
class _FormulaRow extends StatelessWidget {
const _FormulaRow(this.name, this.value);
final String name;
final String value;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2),
child: Row(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: FormulaGlyph(name),
),
),
MacroFigure(kind: MacroKind.kcal, value: value, size: 16),
],
),
);
}
class _SessionTile extends StatelessWidget {
const _SessionTile({required this.result});
@ -106,22 +180,67 @@ class _SessionTile extends StatelessWidget {
Widget build(BuildContext context) {
final session = result.session;
final km = session.km;
final distance = km == null ? '' : ', ${oneDecimal(km)} km';
final kcal = [
for (final name in activityFormulas)
_formulaKcal(name, result.kcal[name]!, result.fallback.contains(name)),
].join(' · ');
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text('${session.day} ${session.label}'),
subtitle: Text(
'${whole(session.minutes)} min$distance\n$kcal kcal',
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(session.day),
const SizedBox(width: AppSpacing.sm),
Icon(
kindIcon(session.kind),
size: 16,
color: muted,
semanticLabel: session.label,
),
if (session.source == 'manual') ...[
const SizedBox(width: AppSpacing.sm),
Flexible(child: Text(session.label)),
],
],
),
Row(
children: [
Icon(
Icons.timer_outlined,
size: 16,
color: muted,
semanticLabel: 'minutes',
),
Text(' ${whole(session.minutes)}'),
if (km != null) ...[
const SizedBox(width: AppSpacing.md),
Icon(Icons.route, size: 16, color: muted, semanticLabel: 'km'),
Text(' ${oneDecimal(km)}'),
],
const SizedBox(width: AppSpacing.md),
Icon(
MacroKind.kcal.icon,
size: 16,
color: MacroKind.kcal.color,
semanticLabel: 'kcal',
),
const SizedBox(width: AppSpacing.xs),
Expanded(
child: Wrap(
spacing: AppSpacing.sm,
children: [
for (final name in activityFormulas)
FormulaFigure(
name,
whole(result.kcal[name]!),
fallback: result.fallback.contains(name),
),
],
),
),
],
),
],
),
isThreeLine: true,
);
}
}
String _formulaKcal(String name, double kcal, bool fallback) =>
'$name ${whole(kcal)}${fallback ? '~' : ''}';

View File

@ -1,52 +1,74 @@
/// A titled card, the building block of every Body screen section.
/// A card, the building block of every Body screen section, plus the
/// icon-led stat rows the cards are made of. No headings: the overview tile
/// that opened the sheet already says what it is.
library;
import 'package:diet_guard_app/services/body_calc.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// A card with a title row and [children] stacked below it.
/// A card with [children] stacked in it; [semanticLabel] names it for screen
/// readers and tests and is never painted.
class BodySection extends StatelessWidget {
/// Creates a section.
const BodySection({
required this.title,
required this.semanticLabel,
required this.children,
super.key,
});
/// Section heading.
final String title;
/// Spoken name of the section.
final String semanticLabel;
/// Section content.
final List<Widget> children;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: AppSpacing.md),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: AppSpacing.sm),
...children,
],
return Semantics(
container: true,
label: semanticLabel,
child: Card(
margin: const EdgeInsets.only(bottom: AppSpacing.md),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
),
);
}
}
/// A label on the left, a value on the right; one line of a stats card.
/// One line of a stats card: a leading mark on the left, a value on the right.
///
/// The mark is either a formula name ([BodyStatRow.named]: a proper noun such
/// as Devine or ACSM, shown as is) or an icon ([BodyStatRow.icon]).
class BodyStatRow extends StatelessWidget {
/// Creates a row.
const BodyStatRow(this.label, this.value, {this.color, super.key});
/// A row led by a formula's proper name.
const BodyStatRow.named(this.name, this.value, {this.color, super.key})
: icon = null,
semanticLabel = null;
/// What the value is.
final String label;
/// A row led by [icon], spoken as [semanticLabel].
const BodyStatRow.icon(
IconData this.icon,
String this.semanticLabel,
this.value, {
this.color,
super.key,
}) : name = null;
/// Proper-noun lead, or null when an icon leads.
final String? name;
/// Icon lead, or null when a name leads.
final IconData? icon;
/// Spoken description of [icon].
final String? semanticLabel;
/// The formatted value.
final String value;
@ -56,11 +78,19 @@ class BodyStatRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final lead = icon;
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2),
child: Row(
children: [
Expanded(child: Text(label)),
Expanded(
child: lead == null
? Text(name!)
: Align(
alignment: Alignment.centerLeft,
child: Icon(lead, size: 20, semanticLabel: semanticLabel),
),
),
Text(
value,
style: TextStyle(
@ -74,28 +104,8 @@ class BodyStatRow extends StatelessWidget {
}
}
/// A muted one-line explanation (missing data, notes).
class BodyNote extends StatelessWidget {
/// Creates a note.
const BodyNote(this.text, {super.key});
/// The note.
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
);
}
}
/// `74.99` -> `"75.0 kg"`, null -> `"n/a"`. Half-up, like the PC.
String formatKg(double? kg) => kg == null ? 'n/a' : '${oneDecimal(kg)} kg';
/// `74.99` -> `"75.0 kg"`, null -> a dash. Half-up, like the PC.
String formatKg(double? kg) => kg == null ? '—' : '${oneDecimal(kg)} kg';
/// [value] to one decimal, rounded half-up like the PC prints it.
String oneDecimal(double value) => halfUp(value, 1).toStringAsFixed(1);

View File

@ -2,9 +2,26 @@
library;
import 'package:diet_guard_app/services/body_calc.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:flutter/material.dart';
/// The BMI band as glyph + colour (never colour alone): a green check inside
/// the healthy range, an amber or red arrow towards the side it is off by.
(IconData, CueKind) bmiBandCue(String category) {
if (category == 'normal') return (Icons.check_circle, CueKind.ok);
if (category.contains('thinness')) {
return (Icons.trending_down, CueKind.warn);
}
if (category.startsWith('overweight')) {
return (Icons.trending_up, CueKind.warn);
}
if (category.startsWith('obese')) return (Icons.trending_up, CueKind.error);
return (Icons.help_outline, CueKind.info);
}
/// BMI, BMI Prime, Trefethen's BMI and the WHO category.
class BmiCard extends StatelessWidget {
/// Creates the card for [bio].
@ -17,30 +34,40 @@ class BmiCard extends StatelessWidget {
Widget build(BuildContext context) {
final report = bmiReport(bio);
final (low, high) = healthyRange(bio);
final normal = report.category == 'normal';
final (icon, kind) = bmiBandCue(report.category);
final tint = kind.tint(context);
return BodySection(
title: 'BMI',
semanticLabel: 'BMI',
children: [
BodyStatRow(
'BMI',
oneDecimal(report.bmi),
color: normal ? null : Theme.of(context).colorScheme.error,
Row(
children: [
Icon(icon, color: tint, semanticLabel: report.category),
const SizedBox(width: AppSpacing.sm),
Text(
oneDecimal(report.bmi),
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(color: tint),
),
],
),
BodyStatRow('Category', report.category),
BodyStatRow('BMI Prime', halfUp(report.prime, 2).toStringAsFixed(2)),
BodyStatRow('Trefethen (new BMI)', oneDecimal(report.trefethen)),
BodyStatRow(
const SizedBox(height: AppSpacing.sm),
BodyStatRow.named(
'BMI Prime',
halfUp(report.prime, 2).toStringAsFixed(2),
),
BodyStatRow.named('Trefethen', oneDecimal(report.trefethen)),
BodyStatRow.icon(
Icons.favorite,
'Healthy weight for your height (BMI 18.5-24.9)',
'${oneDecimal(low)}-${oneDecimal(high)} kg',
),
const BodyNote(bmiPrimeNote),
const BodyNote(trefethenNote),
],
);
}
}
/// Every ideal-weight formula; `n/a` where one is undefined.
/// Every ideal-weight formula; a dash where one is undefined.
class IdealWeightCard extends StatelessWidget {
/// Creates the card for [bio].
const IdealWeightCard({required this.bio, super.key});
@ -51,25 +78,25 @@ class IdealWeightCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final formulas = idealWeights(bio);
final undefined = formulas.any((f) => f.value == null);
final summary = idealSummary(formulas);
return BodySection(
title: 'Ideal weight',
semanticLabel: 'Ideal weight',
children: [
for (final formula in formulas)
BodyStatRow(formula.name, formatKg(formula.value)),
BodyStatRow.named(formula.name, formatKg(formula.value)),
if (summary != null) ...[
const Divider(),
BodyStatRow('Average of all', formatKg(summary.$1)),
BodyStatRow(
BodyStatRow.icon(
Icons.functions,
'Average of all',
formatKg(summary.$1),
),
BodyStatRow.icon(
Icons.swap_horiz,
'Ideal range',
'${oneDecimal(summary.$2)}-${oneDecimal(summary.$3)} kg',
),
],
if (undefined)
const BodyNote(
'Devine, Robinson and Miller are undefined below 152.4 cm.',
),
],
);
}
@ -89,14 +116,29 @@ class BmrCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BodySection(
title: 'Resting metabolism (BMR)',
semanticLabel: 'Resting metabolism (BMR)',
children: [
for (final formula in bmrFormulas(bio, bodyFatPct))
BodyStatRow(
formula.name,
formula.value == null
? 'n/a -- log a body-fat %'
: '${whole(formula.value!)} kcal',
Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2),
child: Row(
children: [
Expanded(child: Text(formula.name)),
if (formula.value == null)
Icon(
Icons.percent,
size: 18,
color: CueKind.warn.tint(context),
semanticLabel: 'n/a -- log a body-fat %',
)
else
MacroFigure(
kind: MacroKind.kcal,
value: whole(formula.value!),
size: 16,
),
],
),
),
],
);

View File

@ -1,22 +1,34 @@
/// The Body screen's overview: two-column metric tiles that fit one phone
/// The Body screen's overview: two-column icon tiles that fit one phone
/// screen. Tapping a tile opens the section's full card in a bottom sheet.
library;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// One tile: a section [title], an optional headline [value], and the
/// [builder] of the full card shown in the sheet.
/// One tile: a big [icon] (spoken as [semanticLabel]), an optional headline
/// [value] and the [builder] of the full card shown in the sheet.
class BodyTileSpec {
/// Creates a tile spec.
const BodyTileSpec({required this.title, required this.builder, this.value});
const BodyTileSpec({
required this.semanticLabel,
required this.icon,
required this.builder,
this.value,
this.valueIcon,
});
/// Section name, the same text the full card carries.
final String title;
/// Spoken name of the section; never painted.
final String semanticLabel;
/// The section's glyph.
final IconData icon;
/// Headline number for the tile (null when the section has none).
final String? value;
/// Small glyph in front of [value] (e.g. a bolt for kcal).
final IconData? valueIcon;
/// Builds the full card for the sheet.
final WidgetBuilder builder;
}
@ -76,33 +88,58 @@ class _Tile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final value = spec.value;
final valueIcon = spec.valueIcon;
return Card(
margin: EdgeInsets.zero,
child: InkWell(
onTap: () => _open(context),
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
spec.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
if (spec.value != null)
Text(
spec.value!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.primary,
child: Semantics(
button: true,
label: spec.semanticLabel,
child: InkWell(
onTap: () => _open(context),
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Row(
children: [
ExcludeSemantics(
child: Icon(
spec.icon,
size: 36,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
// The whole space beside the glyph goes to the value; it
// scales down instead of ellipsizing ("70.0 kg" must show).
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (valueIcon != null)
Icon(
valueIcon,
size: 18,
color: theme.colorScheme.primary,
),
if (value != null)
Text(
value,
maxLines: 1,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
),
),
],
),
),
),
),

View File

@ -0,0 +1,229 @@
/// Dated-value entry card: save or delete any day, undo a delete, and chart
/// the history. Shared by weight and body fat (see `weight_card.dart`).
library;
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:diet_guard_app/widgets/body/weight_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Enters/deletes a day's value and charts the history.
class DatedValueCard extends StatefulWidget {
/// Creates the card.
const DatedValueCard({
required this.semanticLabel,
required this.fieldSemanticLabel,
required this.icon,
required this.unit,
required this.min,
required this.max,
required this.latest,
required this.series,
required this.onSave,
required this.onDelete,
required this.onChanged,
super.key,
});
/// Spoken name of the card; never painted.
final String semanticLabel;
/// Spoken name of the input; never painted.
final String fieldSemanticLabel;
/// Glyph captioning the input.
final IconData icon;
/// Unit suffix (`kg`, `%`).
final String unit;
/// Smallest accepted value.
final double min;
/// Largest accepted value.
final double max;
/// Newest `(day, value)`, or null.
final (String, double)? latest;
/// Every live `(day, value)`, oldest first.
final List<(String, double)> series;
/// Writes a value for a day.
final Future<void> Function(String day, double value) onSave;
/// Deletes a day.
final Future<void> Function(String day) onDelete;
/// Called once a value has been written or deleted.
final VoidCallback onChanged;
@override
State<DatedValueCard> createState() => _DatedValueCardState();
}
class _DatedValueCardState extends State<DatedValueCard> {
final _value = TextEditingController();
String _day = localDateKey(DateTime.now());
Widget? _status;
(String, double)? _undo;
@override
void dispose() {
_value.dispose();
super.dispose();
}
String _fmt(double value) => '${oneDecimal(value)} ${widget.unit}';
Future<void> _pickDay() async {
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
initialDate: DateTime.parse(_day),
);
if (picked == null || !mounted) return;
setState(() => _day = localDateKey(picked));
}
Future<void> _save() async {
final value = double.tryParse(_value.text.replaceAll(',', '.'));
if (value == null || value < widget.min || value > widget.max) {
setState(
() => _status = StatusCue(
key: UniqueKey(),
kind: CueKind.error,
icon: widget.icon,
semanticLabel:
'${widget.semanticLabel} must be ${whole(widget.min)}-'
'${whole(widget.max)} ${widget.unit}.',
),
);
return;
}
await widget.onSave(_day, value);
if (!mounted) return;
_value.clear();
setState(() {
_undo = null;
_status = StatusCue(
key: UniqueKey(),
kind: CueKind.ok,
semanticLabel: 'Saved ${_fmt(value)} for $_day.',
);
});
widget.onChanged();
}
/// Deletes at once; the undo arrow restores the value that was there.
Future<void> _delete() async {
final day = _day;
final removed = [
for (final point in widget.series)
if (point.$1 == day) point,
];
await widget.onDelete(day);
if (!mounted) return;
setState(() {
_undo = removed.isEmpty ? null : removed.first;
_status = StatusCue(
key: UniqueKey(),
kind: CueKind.ok,
icon: Icons.delete_outline,
semanticLabel: 'Deleted $day.',
);
});
widget.onChanged();
}
Future<void> _restore() async {
final undo = _undo;
if (undo == null) return;
await widget.onSave(undo.$1, undo.$2);
if (!mounted) return;
setState(() {
_undo = null;
_status = null;
});
widget.onChanged();
}
@override
Widget build(BuildContext context) {
final latest = widget.latest;
final scheme = Theme.of(context).colorScheme;
final undo = _undo;
return BodySection(
semanticLabel: widget.semanticLabel,
children: [
Row(
children: [
const Icon(Icons.update, size: 18, semanticLabel: 'Latest'),
const SizedBox(width: AppSpacing.xs),
if (latest == null)
Icon(Icons.block, size: 18, color: scheme.outline)
else
// Scales down instead of clipping on a narrow sheet.
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text('${_fmt(latest.$2)} ${latest.$1}', maxLines: 1),
),
),
],
),
Row(
children: [
Expanded(
child: IconField(
icon: widget.icon,
semanticLabel: widget.fieldSemanticLabel,
controller: _value,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
],
),
),
TextButton(
onPressed: _pickDay,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [const Icon(Icons.event, size: 18), Text(' $_day')],
),
),
],
),
Row(
children: [
if (_status != null) _status! else const SizedBox.shrink(),
if (undo != null)
IconButton(
onPressed: _restore,
icon: const Icon(Icons.undo, semanticLabel: 'Undo'),
),
const Spacer(),
IconButton(
icon: const Icon(
Icons.delete_outline,
semanticLabel: 'Delete this day',
),
onPressed: _delete,
),
IconButton.filled(
onPressed: _save,
icon: const Icon(Icons.check, semanticLabel: 'Save'),
),
],
),
WeightChart(series: widget.series),
],
);
}
}

View File

@ -0,0 +1,65 @@
/// A kcal formula shown as a glyph + number; its proper name is only the
/// glyph's spoken label.
library;
import 'package:diet_guard_app/services/activity_kcal.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// One distinct glyph per entry of [activityFormulas].
const Map<String, IconData> formulaIcons = {
'MET': Icons.speed,
'ACSM': Icons.functions,
'Per-km': Icons.straighten,
};
/// The glyph for formula [name], spoken as the formula's proper name.
class FormulaGlyph extends StatelessWidget {
/// Creates a glyph for [name].
const FormulaGlyph(this.name, {this.size = 16, super.key});
/// The formula's proper name (a key of [formulaIcons]).
final String name;
/// Glyph size.
final double size;
// The proper name is spoken only: a tooltip paints on long-press.
@override
Widget build(BuildContext context) => Icon(
formulaIcons[name],
size: size,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: name,
);
}
/// Glyph + number (a trailing `~` marks a figure that reuses the MET value).
class FormulaFigure extends StatelessWidget {
/// Creates a figure.
const FormulaFigure(
this.name,
this.value, {
this.fallback = false,
super.key,
});
/// The formula's proper name.
final String name;
/// The formatted kcal number.
final String value;
/// Whether the figure is a fallback to the MET value.
final bool fallback;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
FormulaGlyph(name),
const SizedBox(width: AppSpacing.xs),
Text(fallback ? '$value~' : value),
],
);
}

View File

@ -1,6 +1,7 @@
/// The Body screen's top line: "To [lose] [0.5] [kg] / [week] on
/// [Moderate] eat 2019 kcal". Every bracket is tappable and opens a picker;
/// the choice is saved (and synced) immediately. Informational only.
/// The Body screen's top line as marks instead of a sentence: direction
/// arrow, amount, unit, per-period calendar, activity glyph, then a bolt and
/// the daily kcal answer. Every mark is tappable and opens a picker; the
/// choice is saved (and synced) immediately. Informational only.
library;
import 'dart:async';
@ -9,12 +10,15 @@ import 'package:diet_guard_app/services/body_calc.dart';
import 'package:diet_guard_app/services/body_energy.dart';
import 'package:diet_guard_app/services/body_prefs.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:diet_guard_app/widgets/body/goal_icons.dart';
import 'package:diet_guard_app/widgets/body/goal_pickers.dart';
import 'package:flutter/material.dart';
/// The goal sentence and its answer.
/// The goal row and its answer.
class GoalCard extends StatefulWidget {
/// Creates the card.
const GoalCard({
@ -47,26 +51,59 @@ class _GoalCardState extends State<GoalCard> {
await setGoal(BodyService.instance, picked);
}
Widget _choice(String text, Future<Goal?> Function(BuildContext, Goal) p) {
/// A bordered, tappable mark spoken as [spoken]; [child] is its look.
Widget _choice(
String spoken,
Widget child,
Future<Goal?> Function(BuildContext, Goal) picker,
) {
final scheme = Theme.of(context).colorScheme;
return InkWell(
onTap: () => unawaited(_pick(p)),
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Text(
text,
style: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: scheme.primary,
return Semantics(
button: true,
label: spoken,
child: InkWell(
onTap: () => unawaited(_pick(picker)),
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: scheme.outline),
),
child: ExcludeSemantics(
child: DefaultTextStyle.merge(
style: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.w600,
),
child: IconTheme.merge(
data: IconThemeData(color: scheme.primary),
child: child,
),
),
),
),
),
);
}
Widget _iconChoice(
String spoken,
IconData icon,
Future<Goal?> Function(BuildContext, Goal) picker, {
String? caption,
}) => _choice(
spoken,
Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(icon), if (caption != null) Text(caption)],
),
picker,
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -76,51 +113,81 @@ class _GoalCardState extends State<GoalCard> {
final tdee = target.tdee;
final maintain = _goal.direction == 'maintain';
return BodySection(
title: 'Daily calorie target',
semanticLabel: 'Daily calorie target',
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: AppSpacing.xs,
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
const Text('To'),
_choice(_goal.direction, pickDirection),
_iconChoice(
_goal.direction,
directionIcon(_goal.direction),
pickDirection,
),
if (!maintain) ...[
_choice(formatG(_goal.amount), pickAmount),
_choice(_goal.weightUnit, pickWeightUnit),
const Text('/'),
_choice(_goal.timeUnit, pickTimeUnit),
],
const Text('on'),
_choice(activityLabel(_goal.activity), pickActivity),
if (kcal != null) ...[
const Text('eat '),
Text(
'$kcal',
style: theme.textTheme.headlineMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
_choice(
formatG(_goal.amount),
Text(formatG(_goal.amount)),
pickAmount,
),
_choice(_goal.weightUnit, Text(_goal.weightUnit), pickWeightUnit),
_iconChoice(
_goal.timeUnit,
timeUnitIcon(_goal.timeUnit),
pickTimeUnit,
),
const Text(' kcal'),
],
_iconChoice(
activityLabel(_goal.activity),
activityIcon(_goal.activity),
pickActivity,
caption: activityCaption(_goal.activity),
),
if (kcal != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(MacroKind.kcal.icon, color: theme.colorScheme.primary),
Text(
'$kcal',
style: theme.textTheme.headlineMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
const SizedBox(height: AppSpacing.sm),
if (kcal == null)
const BodyNote('no workouts published yet (the PC publishes them).'),
const StatusCue(
kind: CueKind.warn,
icon: Icons.directions_run,
semanticLabel: 'no workouts published yet (the PC publishes them).',
),
if (target.belowFloor)
Text(
'That is below the $minTargetKcal kcal floor.',
style: TextStyle(color: theme.colorScheme.error),
const StatusCue(
kind: CueKind.error,
icon: Icons.vertical_align_bottom,
semanticLabel: 'That is below the $minTargetKcal kcal floor.',
),
if (tdee != null)
BodyNote(
'Maintenance ${whole(tdee)} kcal (${target.bmrName} resting '
'burn x activity); 1 kg of fat = ${formatG(kcalPerKg)} kcal.',
Row(
children: [
Icon(
Icons.balance,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
semanticLabel: 'Maintenance',
),
const SizedBox(width: AppSpacing.xs),
MacroFigure(kind: MacroKind.kcal, value: whole(tdee), size: 16),
const SizedBox(width: AppSpacing.sm),
Text(target.bmrName),
],
),
const BodyNote(
'Informational only — the budget is never changed from here.',
),
],
);
}

View File

@ -0,0 +1,45 @@
/// Glyphs for the goal's enumerations (direction, time unit, activity), so
/// the goal reads as a row of icons and numbers instead of a sentence.
library;
import 'package:diet_guard_app/services/body_energy.dart';
import 'package:diet_guard_app/widgets/body/formula_figure.dart';
import 'package:flutter/material.dart';
/// Lose = down arrow, maintain = level bar, gain = up arrow.
IconData directionIcon(String direction) => switch (direction) {
'lose' => Icons.south,
'gain' => Icons.north,
_ => Icons.drag_handle,
};
/// Day / week / month / year as calendar views of growing span.
IconData timeUnitIcon(String unit) => switch (unit) {
'day' => Icons.calendar_view_day,
'week' => Icons.calendar_view_week,
'month' => Icons.calendar_view_month,
_ => Icons.event_repeat,
};
/// The five levels ramp from a chair to a barbell; a measured choice wears
/// its formula's glyph (the same one the Activity sheet shows next to kcal).
IconData activityIcon(String key) => switch (key) {
'sedentary' => Icons.chair,
'light' => Icons.directions_walk,
'moderate' => Icons.directions_bike,
'very' => Icons.directions_run,
'extra' => Icons.fitness_center,
_ when key.startsWith(measuredPrefix) =>
formulaIcons[key.substring(measuredPrefix.length)] ?? Icons.speed,
_ => Icons.speed,
};
/// Numeric caption under an activity icon: the TDEE multiplier of a level.
/// Null for a measured formula (its glyph names it; the proper name is only
/// spoken) and for an unknown key.
String? activityCaption(String key) {
for (final level in activityLevels) {
if (level.key == key) return formatG(level.factor);
}
return null;
}

View File

@ -1,71 +1,141 @@
/// The pickers behind each tappable word of the goal sentence.
/// The pickers behind each tappable mark of the goal row.
///
/// Each returns the edited [Goal], or null when dismissed.
/// Each returns the edited [Goal], or null when dismissed. Options are icons
/// (plus a number or proper name where one exists); the spoken name of every
/// option is its semantic label.
library;
import 'package:diet_guard_app/services/body_energy.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/goal_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// One choice in a picker sheet.
typedef PickerOption = ({
String value,
String spoken,
IconData icon,
String? caption,
});
Future<String?> _sheet(
BuildContext context,
String title,
List<(String, String, String?)> options,
List<PickerOption> options,
String selected,
) => showModalBottomSheet<String>(
context: context,
builder: (context) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
ListTile(title: Text(title)),
for (final (value, label, subtitle) in options)
ListTile(
title: Text(label),
subtitle: subtitle == null ? null : Text(subtitle),
selected: value == selected,
trailing: value == selected ? const Icon(Icons.check) : null,
onTap: () => Navigator.of(context).pop(value),
),
],
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
for (final option in options)
_OptionButton(
option: option,
selected: option.value == selected,
onTap: () => Navigator.of(context).pop(option.value),
),
],
),
),
),
);
class _OptionButton extends StatelessWidget {
const _OptionButton({
required this.option,
required this.selected,
required this.onTap,
});
final PickerOption option;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final tint = selected ? scheme.primary : scheme.onSurfaceVariant;
return Semantics(
button: true,
selected: selected,
label: option.spoken,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
width: 72,
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: selected ? scheme.primary : scheme.outline,
),
color: selected ? scheme.primary.withValues(alpha: 0.16) : null,
),
child: ExcludeSemantics(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(option.icon, color: tint, size: 28),
if (option.caption != null)
Text(
option.caption!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: tint),
),
if (selected)
Icon(Icons.check, size: 14, color: scheme.primary),
],
),
),
),
),
);
}
}
/// Lose / maintain / gain.
Future<Goal?> pickDirection(BuildContext context, Goal goal) async {
final value = await _sheet(context, 'Direction', [
for (final d in directions) (d, d, null),
final value = await _sheet(context, [
for (final d in directions)
(value: d, spoken: d, icon: directionIcon(d), caption: null),
], goal.direction);
return value == null ? null : goal.copyWith(direction: value);
}
/// Kilograms, pounds, grams or stones.
/// Kilograms, pounds, grams or stones (the unit symbols are the captions).
Future<Goal?> pickWeightUnit(BuildContext context, Goal goal) async {
final value = await _sheet(context, 'Weight unit', [
for (final u in weightUnits.keys) (u, u, null),
final value = await _sheet(context, [
for (final u in weightUnits.keys)
(value: u, spoken: u, icon: Icons.scale, caption: u),
], goal.weightUnit);
return value == null ? null : goal.copyWith(weightUnit: value);
}
/// Per day, week, month or year.
Future<Goal?> pickTimeUnit(BuildContext context, Goal goal) async {
final value = await _sheet(context, 'Per', [
for (final u in timeUnits.keys) (u, u, null),
final value = await _sheet(context, [
for (final u in timeUnits.keys)
(value: u, spoken: u, icon: timeUnitIcon(u), caption: null),
], goal.timeUnit);
return value == null ? null : goal.copyWith(timeUnit: value);
}
/// The five levels, then the three measured formulas.
Future<Goal?> pickActivity(BuildContext context, Goal goal) async {
final meaning = {for (final l in activityLevels) l.key: l.meaning};
final value = await _sheet(context, 'Activity', [
final value = await _sheet(context, [
for (final key in activityChoices)
(
key,
activityLabel(key),
meaning[key] ?? 'BMR x 1.2 + your real workouts and steps',
value: key,
spoken: activityLabel(key),
icon: activityIcon(key),
caption: activityCaption(key),
),
], goal.activity);
return value == null ? null : goal.copyWith(activity: value);
@ -95,6 +165,7 @@ class _AmountDialogState extends State<_AmountDialog> {
late final _controller = TextEditingController(
text: formatG(widget.goal.amount),
);
bool _rejected = false;
@override
void dispose() {
@ -104,14 +175,17 @@ class _AmountDialogState extends State<_AmountDialog> {
void _submit() {
final typed = double.tryParse(_controller.text.replaceAll(',', '.'));
if (typed != null && typed >= 0) Navigator.of(context).pop(typed);
if (typed != null && typed >= 0) {
Navigator.of(context).pop(typed);
return;
}
setState(() => _rejected = true);
}
@override
Widget build(BuildContext context) {
final goal = widget.goal;
return AlertDialog(
title: Text('How much (${goal.weightUnit} / ${goal.timeUnit})'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -126,23 +200,29 @@ class _AmountDialogState extends State<_AmountDialog> {
),
],
),
TextField(
IconField(
icon: Icons.edit,
semanticLabel: 'Other amount',
controller: _controller,
decoration: const InputDecoration(labelText: 'Other amount'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
],
onSubmitted: (_) => _submit(),
),
if (_rejected)
const StatusCue(kind: CueKind.error, semanticLabel: 'not a number'),
],
),
actions: [
TextButton(
IconButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
IconButton.filled(
onPressed: _submit,
icon: const Icon(Icons.check, semanticLabel: 'OK'),
),
FilledButton(onPressed: _submit, child: const Text('OK')),
],
);
}

View File

@ -3,6 +3,7 @@ library;
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:flutter/material.dart';
@ -24,7 +25,7 @@ class _ProfileCardState extends State<ProfileCard> {
final _height = TextEditingController();
String? _birth;
String? _sex;
String? _status;
Widget? _status;
@override
void initState() {
@ -59,7 +60,14 @@ class _ProfileCardState extends State<ProfileCard> {
Future<void> _save() async {
final height = double.tryParse(_height.text.replaceAll(',', '.'));
if (height == null || height < 50 || height > 260) {
setState(() => _status = 'Height must be 50-260 cm.');
setState(
() => _status = StatusCue(
key: UniqueKey(),
kind: CueKind.error,
icon: Icons.height,
semanticLabel: 'Height must be 50-260 cm.',
),
);
return;
}
await BodyService.instance.setProfile(
@ -68,28 +76,45 @@ class _ProfileCardState extends State<ProfileCard> {
sex: _sex,
);
if (!mounted) return;
setState(() => _status = 'Saved.');
setState(
() => _status = StatusCue(
key: UniqueKey(),
kind: CueKind.ok,
semanticLabel: 'Saved.',
),
);
widget.onSaved();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final birth = _birth;
final status = _status;
return BodySection(
title: 'Profile',
semanticLabel: 'Profile',
children: [
Row(
children: [
Expanded(child: Text('Born: ${_birth ?? 'not set'}')),
TextButton.icon(
icon: const Icon(Icons.cake),
label: const Text('Birth date'),
onPressed: _pickBirth,
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _pickBirth,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.cake,
color: birth == null ? scheme.error : null,
semanticLabel: 'Birth date',
),
if (birth != null) Text(' $birth'),
],
),
],
),
),
TextField(
IconField(
icon: Icons.height,
semanticLabel: 'Height (cm)',
controller: _height,
decoration: const InputDecoration(labelText: 'Height (cm)'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
@ -97,9 +122,16 @@ class _ProfileCardState extends State<ProfileCard> {
),
const SizedBox(height: AppSpacing.sm),
SegmentedButton<String>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: 'm', label: Text('Male')),
ButtonSegment(value: 'f', label: Text('Female')),
ButtonSegment(
value: 'm',
icon: Icon(Icons.male, semanticLabel: 'Male'),
),
ButtonSegment(
value: 'f',
icon: Icon(Icons.female, semanticLabel: 'Female'),
),
],
selected: {?_sex},
emptySelectionAllowed: true,
@ -109,8 +141,11 @@ class _ProfileCardState extends State<ProfileCard> {
const SizedBox(height: AppSpacing.sm),
Row(
children: [
Expanded(child: BodyNote(_status ?? 'Syncs to the PC.')),
FilledButton(onPressed: _save, child: const Text('Save profile')),
if (status != null) Expanded(child: status) else const Spacer(),
IconButton.filled(
onPressed: _save,
icon: const Icon(Icons.check, semanticLabel: 'Save profile'),
),
],
),
],

View File

@ -4,7 +4,10 @@
/// day overrides it (a planned feast on Saturday too).
library;
import 'package:diet_guard_app/services/body_energy.dart';
import 'package:diet_guard_app/services/week_plan.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:flutter/material.dart';
@ -53,8 +56,7 @@ class _WeekPlanCardState extends State<WeekPlanCard> {
Widget build(BuildContext context) {
final target = int.tryParse(_target.text) ?? widget.defaultTarget;
final typed = <int, int>{
for (var i = 0; i < 7; i++)
i: ?int.tryParse(_days[i].text),
for (var i = 0; i < 7; i++) i: ?int.tryParse(_days[i].text),
};
final plan = planWeek(
widget.today ?? DateTime.now(),
@ -64,41 +66,39 @@ class _WeekPlanCardState extends State<WeekPlanCard> {
);
final scheme = Theme.of(context).colorScheme;
return BodySection(
title: 'Week planner (info only)',
semanticLabel: 'Week planner',
children: [
TextField(
controller: _target,
decoration: InputDecoration(
labelText: 'Weekly average target (kcal/day)',
hintText: '${widget.defaultTarget}',
SpokenLabel(
semanticLabel: 'Weekly average target (kcal/day)',
child: TextField(
controller: _target,
decoration: InputDecoration(
prefixIcon: ExcludeSemantics(
child: Icon(MacroKind.kcal.icon, color: MacroKind.kcal.color),
),
hintText: '${widget.defaultTarget}',
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => setState(() {}),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => setState(() {}),
),
const SizedBox(height: AppSpacing.sm),
for (var i = 0; i < 7; i++)
Row(
children: [
SizedBox(width: 48, child: Text(plan.days[i].weekday)),
// Day of month, not a weekday word: rows run Monday..Sunday.
SizedBox(width: 32, child: Text(plan.days[i].day.substring(8))),
Expanded(
child: Text(
'${plan.days[i].kcal} kcal ${_sourceLabel(plan.days[i])}',
style: TextStyle(
color: plan.days[i].source == 'plan' && plan.belowFloor
? scheme.error
: null,
),
),
child: _DayFigure(plan.days[i], belowFloor: plan.belowFloor),
),
SizedBox(
width: 96,
child: TextField(
child: IconField(
icon: Icons.rice_bowl,
semanticLabel: 'ate…',
controller: _days[i],
decoration: const InputDecoration(
hintText: 'ate…',
isDense: true,
),
dense: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => setState(() {}),
@ -107,26 +107,86 @@ class _WeekPlanCardState extends State<WeekPlanCard> {
],
),
const SizedBox(height: AppSpacing.sm),
BodyNote(_summary(plan)),
_Summary(plan, scheme),
],
);
}
}
String _sourceLabel(DayPlan day) => switch (day.source) {
'typed' => '(typed)',
'logged' => '(logged)',
'unlogged' => '(not logged -- type it)',
_ => '(plan)',
};
/// A day's kcal with a glyph for where the number came from.
class _DayFigure extends StatelessWidget {
const _DayFigure(this.day, {required this.belowFloor});
String _summary(WeekPlan plan) {
final per = plan.perRemaining;
if (per == null) {
return 'Every day is known: week average '
'${whole(plan.weekAvg)} kcal.';
final DayPlan day;
final bool belowFloor;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final (icon, spoken) = switch (day.source) {
'typed' => (Icons.edit, '(typed)'),
'logged' => (Icons.check, '(logged)'),
'unlogged' => (Icons.help_outline, '(not logged -- type it)'),
_ => (Icons.insights, '(plan)'),
};
final bad = day.source == 'plan' && belowFloor;
final unlogged = day.source == 'unlogged';
final tint = bad
? scheme.error
: unlogged
? CueKind.warn.tint(context)
: null;
return Row(
children: [
MacroFigure(kind: MacroKind.kcal, value: '${day.kcal}', size: 16),
const SizedBox(width: AppSpacing.sm),
Icon(icon, size: 16, color: tint, semanticLabel: spoken),
],
);
}
}
/// What to eat on the open days, or the finished week's average.
class _Summary extends StatelessWidget {
const _Summary(this.plan, this.scheme);
final WeekPlan plan;
final ColorScheme scheme;
@override
Widget build(BuildContext context) {
final per = plan.perRemaining;
if (per == null) {
return Row(
children: [
Icon(
Icons.check_circle,
color: CueKind.ok.tint(context),
semanticLabel: 'Every day is known: week average',
),
const SizedBox(width: AppSpacing.xs),
MacroFigure(kind: MacroKind.kcal, value: whole(plan.weekAvg)),
],
);
}
return Row(
children: [
const Icon(Icons.restaurant, size: 18, semanticLabel: 'Eat'),
const SizedBox(width: AppSpacing.xs),
MacroFigure(kind: MacroKind.kcal, value: '$per'),
const SizedBox(width: AppSpacing.md),
const Icon(Icons.flag_outlined, size: 18, semanticLabel: 'average'),
const SizedBox(width: AppSpacing.xs),
Text('${plan.targetAvg}'),
if (plan.belowFloor) ...[
const SizedBox(width: AppSpacing.md),
const StatusCue(
kind: CueKind.error,
icon: Icons.vertical_align_bottom,
semanticLabel: 'below the $minTargetKcal kcal floor',
),
],
],
);
}
final floor = plan.belowFloor ? ' -- below the 1200 kcal floor!' : '';
return 'Eat $per kcal on each open day to average '
'${plan.targetAvg}$floor';
}

View File

@ -5,12 +5,11 @@
library;
import 'package:diet_guard_app/models/body_document.dart';
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:diet_guard_app/widgets/body/weight_chart.dart';
import 'package:diet_guard_app/widgets/body/dated_value_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
export 'package:diet_guard_app/widgets/body/dated_value_card.dart';
/// Weight: 20-400 kg; wake-alarm weigh-ins land here too.
class WeightCard extends StatelessWidget {
@ -24,12 +23,12 @@ class WeightCard extends StatelessWidget {
Widget build(BuildContext context) {
final body = BodyService.instance;
return DatedValueCard(
title: 'Weight',
fieldLabel: 'Weight (kg)',
semanticLabel: 'Weight',
fieldSemanticLabel: 'Weight (kg)',
icon: Icons.monitor_weight,
unit: 'kg',
min: 20,
max: 400,
hint: 'Morning weigh-ins from wake-alarm appear here.',
latest: body.latestWeight,
series: body.weightSeries,
onSave: body.setWeight,
@ -51,12 +50,12 @@ class BodyFatCard extends StatelessWidget {
Widget build(BuildContext context) {
final body = BodyService.instance;
return DatedValueCard(
title: 'Body fat',
fieldLabel: 'Body fat (%)',
semanticLabel: 'Body fat',
fieldSemanticLabel: 'Body fat (%)',
icon: Icons.percent,
unit: '%',
min: minBodyFat,
max: maxBodyFat,
hint: 'With a body fat %, targets use the lean-mass (Katch-McArdle) BMR.',
latest: body.latestBodyFat,
series: body.bodyFatSeries,
onSave: body.setBodyFat,
@ -65,150 +64,3 @@ class BodyFatCard extends StatelessWidget {
);
}
}
/// Enters/deletes a day's value and charts the history.
class DatedValueCard extends StatefulWidget {
/// Creates the card.
const DatedValueCard({
required this.title,
required this.fieldLabel,
required this.unit,
required this.min,
required this.max,
required this.hint,
required this.latest,
required this.series,
required this.onSave,
required this.onDelete,
required this.onChanged,
super.key,
});
/// Section heading.
final String title;
/// Input label.
final String fieldLabel;
/// Unit suffix (`kg`, `%`).
final String unit;
/// Smallest accepted value.
final double min;
/// Largest accepted value.
final double max;
/// Note shown until something is saved.
final String hint;
/// Newest `(day, value)`, or null.
final (String, double)? latest;
/// Every live `(day, value)`, oldest first.
final List<(String, double)> series;
/// Writes a value for a day.
final Future<void> Function(String day, double value) onSave;
/// Deletes a day.
final Future<void> Function(String day) onDelete;
/// Called once a value has been written or deleted.
final VoidCallback onChanged;
@override
State<DatedValueCard> createState() => _DatedValueCardState();
}
class _DatedValueCardState extends State<DatedValueCard> {
final _value = TextEditingController();
String _day = localDateKey(DateTime.now());
String? _status;
@override
void dispose() {
_value.dispose();
super.dispose();
}
String _fmt(double value) => '${oneDecimal(value)} ${widget.unit}';
Future<void> _pickDay() async {
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
initialDate: DateTime.parse(_day),
);
if (picked == null || !mounted) return;
setState(() => _day = localDateKey(picked));
}
Future<void> _save() async {
final value = double.tryParse(_value.text.replaceAll(',', '.'));
if (value == null || value < widget.min || value > widget.max) {
setState(
() => _status =
'${widget.title} must be ${whole(widget.min)}-'
'${whole(widget.max)} ${widget.unit}.',
);
return;
}
await widget.onSave(_day, value);
if (!mounted) return;
_value.clear();
setState(() => _status = 'Saved ${_fmt(value)} for $_day.');
widget.onChanged();
}
Future<void> _delete() async {
await widget.onDelete(_day);
if (!mounted) return;
setState(() => _status = 'Deleted $_day.');
widget.onChanged();
}
@override
Widget build(BuildContext context) {
final latest = widget.latest;
return BodySection(
title: widget.title,
children: [
BodyStatRow(
'Latest',
latest == null ? 'none yet' : '${_fmt(latest.$2)} (${latest.$1})',
),
Row(
children: [
Expanded(
child: TextField(
controller: _value,
decoration: InputDecoration(labelText: widget.fieldLabel),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
],
),
),
TextButton(onPressed: _pickDay, child: Text(_day)),
],
),
Row(
children: [
Expanded(child: BodyNote(_status ?? widget.hint)),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete this day',
onPressed: _delete,
),
FilledButton(onPressed: _save, child: const Text('Save')),
],
),
WeightChart(series: widget.series),
],
);
}
}

View File

@ -4,11 +4,12 @@
/// drawn to scale rather than evenly spaced.
library;
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
/// Selectable chart ranges, `(label, days)`; null days = everything.
/// Selectable chart ranges, `(spoken name, days)`; null days = everything.
const List<(String, int?)> weightChartRanges = [
('30d', 30),
('90d', 90),
@ -61,11 +62,19 @@ class _WeightChartState extends State<WeightChart> {
Wrap(
spacing: AppSpacing.sm,
children: [
for (final (label, days) in weightChartRanges)
ChoiceChip(
label: Text(label),
selected: _days == days,
onSelected: (_) => setState(() => _days = days),
for (final (spoken, days) in weightChartRanges)
SpokenLabel(
semanticLabel: spoken,
child: ChoiceChip(
// Days as a bare numeral; "everything" as the infinity glyph.
label: ExcludeSemantics(
child: days == null
? const Icon(Icons.all_inclusive, size: 18)
: Text('$days'),
),
selected: _days == days,
onSelected: (_) => setState(() => _days = days),
),
),
],
),
@ -73,7 +82,14 @@ class _WeightChartState extends State<WeightChart> {
SizedBox(
height: 200,
child: points.length < 2
? const Center(child: Text('Need two readings for a graph.'))
? Center(
child: Icon(
Icons.show_chart,
size: 64,
color: Theme.of(context).colorScheme.outline,
semanticLabel: 'Need two readings for a graph.',
),
)
: _chart(context, points),
),
],

View File

@ -0,0 +1,13 @@
/// Returns the activity-type icon for [kind].
library;
import 'package:flutter/material.dart';
/// Activity kind icons used by _SessionTile (in `activity_card.dart`).
IconData kindIcon(String kind) => switch (kind) {
'run' => Icons.directions_run,
'walk' => Icons.directions_walk,
'cycle' => Icons.directions_bike,
'strength' => Icons.fitness_center,
_ => Icons.sports,
};

View File

@ -46,23 +46,6 @@ class DayStatusCalendar extends StatelessWidget {
/// outside the displayed month is never tappable regardless.
final void Function(DateTime day)? onDaySelected;
static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
static const _monthNames = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
String _dateKey(int year, int m, int day) =>
'$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
@ -129,13 +112,18 @@ class DayStatusCalendar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.chevron_left, color: scheme.onSurfaceVariant),
icon: Icon(
Icons.chevron_left,
color: scheme.onSurfaceVariant,
semanticLabel: 'Previous month',
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
onPressed: onPrevMonth,
),
// Numeric month/year: a month name would be written chrome.
Text(
'${_monthNames[m - 1]} $year',
'${m.toString().padLeft(2, '0')} · $year',
style: TextStyle(
color: scheme.onSurface,
fontWeight: FontWeight.bold,
@ -143,7 +131,11 @@ class DayStatusCalendar extends StatelessWidget {
),
),
IconButton(
icon: Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
icon: Icon(
Icons.chevron_right,
color: scheme.onSurfaceVariant,
semanticLabel: 'Next month',
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
onPressed: onNextMonth,
@ -153,25 +145,23 @@ class DayStatusCalendar extends StatelessWidget {
const SizedBox(height: AppSpacing.xs + 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: _weekHeaders
.map(
(h) => SizedBox(
width: 30,
child: Text(
h,
textAlign: TextAlign.center,
// Two-letter day abbreviations are UI chrome (like a
// badge/tag), deliberately kept below the 16px body
// floor rather than forced to grow past their slot.
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: AppTextSize.label - 2,
fontWeight: FontWeight.bold,
),
// Weekday names are gone: seven dots mark the Monday-first
// columns, the two weekend ones in the accent colour.
children: [
for (var col = 0; col < 7; col++)
SizedBox(
width: 30,
child: Center(
child: Icon(
Icons.circle,
size: col >= 5 ? 8 : 5,
color: col >= 5
? scheme.primary
: scheme.onSurfaceVariant,
),
),
)
.toList(),
),
],
),
const SizedBox(height: AppSpacing.xs),
...List.generate(rows, (row) {
@ -218,9 +208,10 @@ class DayStatusCalendar extends StatelessWidget {
// and no semantics for a screen reader. InkWell is a focus
// stop and is activated by Enter/Space.
final date = DateTime(year, m, day);
final semanticLabel = 'View $day/$m/$year';
return Semantics(
button: true,
label: 'View $day/$m/$year',
label: semanticLabel,
child: InkWell(
onTap: () => onDaySelected!(date),
customBorder: const CircleBorder(),

View File

@ -9,8 +9,8 @@ import 'package:diet_guard_app/services/kuchnia_client.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// Resting label, mirroring the PC gate's "🍱 Fill all".
const fillAllLabel = '🍱 Fill all with catering';
/// Spoken name of the resting button (never painted).
const fillAllLabel = 'Fill all with catering';
/// Fill-all button; relabels to "✓ Confirm (N)" once a proposal is armed.
class FillAllRow extends StatelessWidget {
@ -52,26 +52,42 @@ class FillAllRow extends StatelessWidget {
Expanded(
child: OutlinedButton(
onPressed: busy ? null : onFill,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(_label(armed), maxLines: 1),
),
child: _face(context, armed),
),
),
if (armed) ...[
const SizedBox(width: AppSpacing.sm),
TextButton(
IconButton(
onPressed: busy ? null : onCancel,
child: const Text('Cancel'),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
],
],
);
}
String _label(bool armed) {
if (busy) return 'Working…';
if (armed) return '✓ Confirm ($armedCount)';
return fillAllLabel;
/// Resting glyph; spinner while busy; double check + count once armed.
Widget _face(BuildContext context, bool armed) {
if (busy) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
if (!armed) {
return const Icon(Icons.playlist_add_check, semanticLabel: fillAllLabel);
}
final success =
(Theme.of(context).extension<AppStatusColors>() ?? AppStatusColors.dark)
.success;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.done_all, color: success, semanticLabel: 'Confirm'),
const SizedBox(width: AppSpacing.xs),
Text('$armedCount'),
],
);
}
}

View File

@ -0,0 +1,124 @@
/// Icon-only chrome shared by the history and food-bank filter sheets: the
/// clear control, the apply bar and the sort chooser. Each control paints a
/// glyph and speaks its old caption only through its semantic label.
library;
import 'package:flutter/material.dart';
/// Top row of a filter sheet: one clear-everything icon, right-aligned.
class FilterSheetHeader extends StatelessWidget {
/// Creates a header whose clear icon calls [onClear].
const FilterSheetHeader({required this.onClear, super.key});
/// Called when the clear icon is pressed.
final VoidCallback onClear;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 0),
child: Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: const Icon(Icons.filter_list_off, semanticLabel: 'Clear all'),
onPressed: onClear,
),
),
);
}
/// Bottom bar of a filter sheet: one full-width check button.
class FilterApplyBar extends StatelessWidget {
/// Creates an apply bar that calls [onApply].
const FilterApplyBar({required this.onApply, super.key});
/// Called when the check is pressed.
final VoidCallback onApply;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onApply,
child: const Icon(Icons.check, semanticLabel: 'Apply'),
),
),
);
}
/// One choice in a [SortChoiceRow]: a glyph, its tint and its spoken name.
class SortOption<T> {
/// Creates an option selecting [value].
const SortOption(this.value, this.icon, this.semanticLabel, {this.color});
/// The value reported when chosen.
final T value;
/// Painted glyph.
final IconData icon;
/// Spoken name; never painted.
final String semanticLabel;
/// Glyph tint; the theme default when null.
final Color? color;
}
/// Icon chips for the sort field plus an arrow for the direction.
class SortChoiceRow<T> extends StatelessWidget {
/// Creates a chooser over [options] with [selected] active.
const SortChoiceRow({
required this.options,
required this.selected,
required this.ascending,
required this.onChanged,
super.key,
});
/// Choices in display order.
final List<SortOption<T>> options;
/// The active choice.
final T selected;
/// Whether the sort runs ascending.
final bool ascending;
/// Called with the new field and direction.
final void Function({required T field, required bool asc}) onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final o in options)
ChoiceChip(
label: Icon(
o.icon,
size: 18,
color: o.color,
semanticLabel: o.semanticLabel,
),
selected: selected == o.value,
onSelected: (_) => onChanged(field: o.value, asc: ascending),
),
],
),
),
IconButton(
icon: Icon(
ascending ? Icons.arrow_upward : Icons.arrow_downward,
semanticLabel: ascending ? 'Ascending' : 'Descending',
),
onPressed: () => onChanged(field: selected, asc: !ascending),
),
],
);
}
}

View File

@ -6,9 +6,11 @@
/// screen owns the other's controls.
library;
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:flutter/material.dart';
/// The free-text "search by name" field in the filter sheet.
/// The free-text search-by-name field in the filter sheet.
///
/// Stateful only to own its [TextEditingController]; the query itself lives in
/// the screen's [HistoryFilter].
@ -44,13 +46,11 @@ class NameSearchFieldState extends State<NameSearchField> {
@override
Widget build(BuildContext context) {
return TextField(
return IconField(
icon: Icons.search,
semanticLabel: 'Search by name',
controller: _controller,
decoration: const InputDecoration(
labelText: 'Search by name',
prefixIcon: Icon(Icons.search),
isDense: true,
),
dense: true,
onChanged: widget.onChanged,
);
}
@ -112,7 +112,7 @@ class SliderSelectedLabel extends StatelessWidget {
class FilterRangeRow extends StatelessWidget {
/// Creates a range row bounded by [maxValue].
const FilterRangeRow({
required this.label,
required this.kind,
required this.sliderKey,
required this.maxValue,
required this.min,
@ -123,8 +123,8 @@ class FilterRangeRow extends StatelessWidget {
super.key,
});
/// Section heading, e.g. `Protein range (g)`.
final String label;
/// Which nutrient this range filters; its glyph heads the row.
final MacroKind kind;
/// Widget key on the slider itself, so tests can drive one row.
final Key sliderKey;
@ -158,7 +158,14 @@ class FilterRangeRow extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
Align(
alignment: Alignment.centerLeft,
child: Icon(
kind.icon,
color: kind.color,
semanticLabel: kind.semanticLabel(compact: false),
),
),
if (showEndpointLabels)
SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'),
RangeSlider(

View File

@ -4,6 +4,8 @@
library;
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:flutter/material.dart';
/// Prompts for a new manually-curated food bank entry.
@ -53,49 +55,50 @@ class AddEntryDialogState extends State<AddEntryDialog> {
);
}
Widget _field(String label, TextEditingController ctrl) => Padding(
Widget _field(MacroKind kind, TextEditingController ctrl) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
child: IconField(
icon: kind.icon,
iconColor: kind.color,
semanticLabel: kind.semanticLabel(compact: false),
controller: ctrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: label, isDense: true),
dense: true,
),
);
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add to food bank'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
child: IconField(
icon: Icons.restaurant,
semanticLabel: 'Name',
controller: _name,
decoration: const InputDecoration(
labelText: 'Name',
isDense: true,
),
dense: true,
),
),
_field('Reference grams', _grams),
_field('Kcal', _kcal),
_field('Protein (g)', _protein),
_field('Carbs (g)', _carbs),
_field('Fat (g)', _fat),
_field(MacroKind.perGrams, _grams),
_field(MacroKind.kcal, _kcal),
_field(MacroKind.protein, _protein),
_field(MacroKind.carbs, _carbs),
_field(MacroKind.fat, _fat),
],
),
),
actions: [
TextButton(
IconButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
ElevatedButton(
onPressed: _save,
child: const Text('Save to bank'),
child: const Icon(Icons.check, semanticLabel: 'Save to bank'),
),
],
);

View File

@ -4,6 +4,9 @@
/// range rows come from `widgets/filters/`, shared with the history sheet.
library;
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/widgets/filters/filter_chrome.dart';
import 'package:diet_guard_app/widgets/filters/filter_fields.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart';
import 'package:flutter/material.dart';
@ -63,6 +66,35 @@ class FbFilterSheet extends StatelessWidget {
/// Called when the user clears every criterion.
final VoidCallback onClear;
static final List<SortOption<FbSortField>> _sortOptions = [
const SortOption(FbSortField.count, Icons.repeat, 'Usage count'),
const SortOption(FbSortField.name, Icons.sort_by_alpha, 'Name'),
SortOption(
FbSortField.kcal,
MacroKind.kcal.icon,
'Kcal',
color: MacroKind.kcal.color,
),
SortOption(
FbSortField.protein,
MacroKind.protein.icon,
'Protein',
color: MacroKind.protein.color,
),
SortOption(
FbSortField.carbs,
MacroKind.carbs.icon,
'Carbs',
color: MacroKind.carbs.color,
),
SortOption(
FbSortField.fat,
MacroKind.fat.icon,
'Fat',
color: MacroKind.fat.color,
),
];
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
@ -71,35 +103,17 @@ class FbFilterSheet extends StatelessWidget {
maxChildSize: 0.95,
builder: (context, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
child: Row(
children: [
Expanded(
child: Text(
'Filter & Sort',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton(
onPressed: onClear,
child: const Text('Clear all'),
),
],
),
),
FilterSheetHeader(onClear: onClear),
const Divider(),
Expanded(
child: ListView(
controller: scroll,
padding: const EdgeInsets.all(16),
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Search by name',
prefixIcon: Icon(Icons.search),
isDense: true,
),
IconField(
icon: Icons.search,
semanticLabel: 'Search by name',
dense: true,
controller: TextEditingController(text: filter.nameQuery)
..selection = TextSelection.collapsed(
offset: filter.nameQuery.length,
@ -111,7 +125,7 @@ class FbFilterSheet extends StatelessWidget {
),
const SizedBox(height: 16),
FilterRangeRow(
label: 'Kcal range',
kind: MacroKind.kcal,
sliderKey: const Key('fb-kcal-range-slider'),
maxValue: maxKcal,
min: filter.minKcal,
@ -124,7 +138,7 @@ class FbFilterSheet extends StatelessWidget {
},
),
FilterRangeRow(
label: 'Protein range (g)',
kind: MacroKind.protein,
sliderKey: const Key('fb-protein-range-slider'),
maxValue: maxProtein,
min: filter.minProtein,
@ -138,7 +152,7 @@ class FbFilterSheet extends StatelessWidget {
},
),
FilterRangeRow(
label: 'Carbs range (g)',
kind: MacroKind.carbs,
sliderKey: const Key('fb-carbs-range-slider'),
maxValue: maxCarbs,
min: filter.minCarbs,
@ -152,7 +166,7 @@ class FbFilterSheet extends StatelessWidget {
},
),
FilterRangeRow(
label: 'Fat range (g)',
kind: MacroKind.fat,
sliderKey: const Key('fb-fat-range-slider'),
maxValue: maxFat,
min: filter.minFat,
@ -165,74 +179,16 @@ class FbFilterSheet extends StatelessWidget {
onFilterChanged(filter);
},
),
Text(
'Sort by',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButton<FbSortField>(
isExpanded: true,
value: sortField,
items: const [
DropdownMenuItem(
value: FbSortField.count,
child: Text('Usage count'),
),
DropdownMenuItem(
value: FbSortField.name,
child: Text('Name'),
),
DropdownMenuItem(
value: FbSortField.kcal,
child: Text('Kcal'),
),
DropdownMenuItem(
value: FbSortField.protein,
child: Text('Protein'),
),
DropdownMenuItem(
value: FbSortField.carbs,
child: Text('Carbs'),
),
DropdownMenuItem(
value: FbSortField.fat,
child: Text('Fat'),
),
],
onChanged: (v) {
if (v != null) {
onSortChanged(field: v, asc: ascending);
}
},
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
ascending ? Icons.arrow_upward : Icons.arrow_downward,
),
tooltip: ascending ? 'Ascending' : 'Descending',
onPressed: () =>
onSortChanged(field: sortField, asc: !ascending),
),
],
SortChoiceRow<FbSortField>(
options: _sortOptions,
selected: sortField,
ascending: ascending,
onChanged: onSortChanged,
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onApply,
child: const Text('Apply'),
),
),
),
FilterApplyBar(onApply: onApply),
],
),
);

View File

@ -2,9 +2,13 @@
library;
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:flutter/material.dart';
/// One banked food: its name, macros, and how often it has been logged.
///
/// Macros are glyph + number pairs; the reference weight carries the scale
/// glyph, the use count keeps its `×N` numeral.
class RecordTile extends StatelessWidget {
/// Creates a [RecordTile] for [record].
const RecordTile(this.record, {super.key});
@ -14,19 +18,23 @@ class RecordTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final macros =
'P ${record.proteinG.toStringAsFixed(0)} g '
'C ${record.carbsG.toStringAsFixed(0)} g '
'F ${record.fatG.toStringAsFixed(0)} g';
final per = record.grams > 0
? ' per ${record.grams.toStringAsFixed(0)} g'
: '';
String n(double v) => v.toStringAsFixed(0);
return ListTile(
title: Text(record.desc),
subtitle: Text('${record.kcal.toStringAsFixed(0)} kcal$per · $macros'),
subtitle: Wrap(
spacing: 12,
children: [
MacroFigure(kind: MacroKind.kcal, value: n(record.kcal)),
if (record.grams > 0)
MacroFigure(kind: MacroKind.perGrams, value: '${n(record.grams)}g'),
MacroFigure(kind: MacroKind.protein, value: '${n(record.proteinG)}g'),
MacroFigure(kind: MacroKind.carbs, value: '${n(record.carbsG)}g'),
MacroFigure(kind: MacroKind.fat, value: '${n(record.fatG)}g'),
],
),
trailing: record.count > 0
? Text(
'×${record.count.toStringAsFixed(0)}',
'×${n(record.count)}',
style: Theme.of(context).textTheme.bodySmall,
)
: null,

View File

@ -4,6 +4,7 @@
library;
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
@ -59,11 +60,13 @@ class FbTile extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(
'${record.kcal.toStringAsFixed(0)} kcal',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
child: Align(
alignment: Alignment.centerLeft,
child: MacroFigure(
kind: MacroKind.kcal,
value: record.kcal.toStringAsFixed(0),
size: 14,
),
),
),
if (record.count > 0)

View File

@ -3,6 +3,8 @@
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
library;
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/widgets/filters/filter_chrome.dart';
import 'package:diet_guard_app/widgets/filters/filter_fields.dart';
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sort.dart';
@ -73,20 +75,7 @@ class FilterSheet extends StatelessWidget {
maxChildSize: 0.95,
builder: (context, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
child: Row(
children: [
Expanded(
child: Text(
'Filter & Sort',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton(onPressed: onClear, child: const Text('Clear all')),
],
),
),
FilterSheetHeader(onClear: onClear),
const Divider(),
Expanded(
child: SingleChildScrollView(
@ -105,18 +94,21 @@ class FilterSheet extends StatelessWidget {
),
const SizedBox(height: 16),
// Date range
Text(
'Date range',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
OutlinedButton.icon(
icon: const Icon(Icons.date_range),
label: Text(
filter.dateRange == null
? 'Any date'
: dateRangeLabel(filter.dateRange!),
// Date range: the glyph alone means "any date"; once a range
// is set its dates (data, not chrome) appear beside it.
OutlinedButton(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.date_range,
semanticLabel: 'Date range',
),
if (filter.dateRange != null) ...[
const SizedBox(width: 8),
Text(dateRangeLabel(filter.dateRange!)),
],
],
),
onPressed: () async {
final picked = await showDateRangePicker(
@ -132,17 +124,20 @@ class FilterSheet extends StatelessWidget {
},
),
if (filter.dateRange != null)
TextButton(
IconButton(
onPressed: () {
filter.dateRange = null;
onFilterChanged(filter);
},
child: const Text('Clear date range'),
icon: const Icon(
Icons.event_busy,
semanticLabel: 'Clear date range',
),
),
const SizedBox(height: 16),
FilterRangeRow(
label: 'Kcal range',
kind: MacroKind.kcal,
sliderKey: const Key('kcal-range-slider'),
maxValue: maxKcal,
min: filter.minKcal,
@ -154,7 +149,7 @@ class FilterSheet extends StatelessWidget {
},
),
FilterRangeRow(
label: 'Protein range (g)',
kind: MacroKind.protein,
sliderKey: const Key('protein-range-slider'),
maxValue: maxProtein,
min: filter.minProtein,
@ -167,7 +162,7 @@ class FilterSheet extends StatelessWidget {
},
),
FilterRangeRow(
label: 'Carbs range (g)',
kind: MacroKind.carbs,
sliderKey: const Key('carbs-range-slider'),
maxValue: maxCarbs,
min: filter.minCarbs,
@ -180,7 +175,7 @@ class FilterSheet extends StatelessWidget {
},
),
FilterRangeRow(
label: 'Fat range (g)',
kind: MacroKind.fat,
sliderKey: const Key('fat-range-slider'),
maxValue: maxFat,
min: filter.minFat,
@ -210,16 +205,7 @@ class FilterSheet extends StatelessWidget {
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onApply,
child: const Text('Apply'),
),
),
),
FilterApplyBar(onApply: onApply),
],
),
);

View File

@ -3,8 +3,12 @@
/// Split from `history_filter_sheet.dart` for the repo's 250-line cap. Both are
/// stateless and report upward: the live filter and sort order belong to the
/// screen, so nothing here holds state that could disagree with what is shown.
/// Every control is a glyph; its old caption lives only in the semantic label.
library;
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/source_icon.dart';
import 'package:diet_guard_app/widgets/filters/filter_chrome.dart';
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:flutter/material.dart';
@ -15,7 +19,7 @@ import 'package:flutter/material.dart';
/// a constraint on what `source` may contain.
const _sources = ['manual', 'food bank'];
/// Single-select chips filtering by entry source, with an "All" reset.
/// Single-select chips filtering by entry source, with an "all" reset.
class FilterSourceChips extends StatelessWidget {
/// Creates source chips with [selected] currently active.
const FilterSourceChips({
@ -24,41 +28,38 @@ class FilterSourceChips extends StatelessWidget {
super.key,
});
/// The active source, or null for "All".
/// The active source, or null for "all".
final String? selected;
/// Called with the chosen source, or null when "All" is tapped.
/// Called with the chosen source, or null when the reset chip is tapped.
final ValueChanged<String?> onSelected;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
return Wrap(
spacing: 8,
children: [
Text('Source', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('All'),
selected: selected == null,
onSelected: (_) => onSelected(null),
),
for (final src in _sources)
FilterChip(
label: Text(src),
selected: selected == src,
onSelected: (_) => onSelected(src),
),
],
FilterChip(
label: const Icon(
Icons.all_inclusive,
size: 18,
semanticLabel: 'All',
),
selected: selected == null,
onSelected: (_) => onSelected(null),
),
for (final src in _sources)
FilterChip(
label: SourceMark(src, size: 18),
selected: selected == src,
onSelected: (_) => onSelected(src),
),
],
);
}
}
/// The sort-field dropdown plus its ascending/descending toggle.
/// The sort-field chips plus their ascending/descending toggle.
class FilterSortControls extends StatelessWidget {
/// Creates sort controls reflecting [sortField] and [ascending].
const FilterSortControls({
@ -78,63 +79,44 @@ class FilterSortControls extends StatelessWidget {
final void Function({required HistorySortField field, required bool asc})
onSortChanged;
static final List<SortOption<HistorySortField>> _options = [
const SortOption(HistorySortField.date, Icons.calendar_today, 'Date'),
SortOption(
HistorySortField.kcal,
MacroKind.kcal.icon,
'Kcal',
color: MacroKind.kcal.color,
),
SortOption(
HistorySortField.protein,
MacroKind.protein.icon,
'Protein',
color: MacroKind.protein.color,
),
SortOption(
HistorySortField.carbs,
MacroKind.carbs.icon,
'Carbs',
color: MacroKind.carbs.color,
),
SortOption(
HistorySortField.fat,
MacroKind.fat.icon,
'Fat',
color: MacroKind.fat.color,
),
const SortOption(
HistorySortField.description,
Icons.sort_by_alpha,
'Description',
),
];
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Sort by', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButton<HistorySortField>(
isExpanded: true,
value: sortField,
items: const [
DropdownMenuItem(
value: HistorySortField.date,
child: Text('Date'),
),
DropdownMenuItem(
value: HistorySortField.kcal,
child: Text('Kcal'),
),
DropdownMenuItem(
value: HistorySortField.protein,
child: Text('Protein'),
),
DropdownMenuItem(
value: HistorySortField.carbs,
child: Text('Carbs'),
),
DropdownMenuItem(
value: HistorySortField.fat,
child: Text('Fat'),
),
DropdownMenuItem(
value: HistorySortField.description,
child: Text('Description'),
),
],
onChanged: (v) {
if (v != null) {
onSortChanged(field: v, asc: ascending);
}
},
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
ascending ? Icons.arrow_upward : Icons.arrow_downward,
),
tooltip: ascending ? 'Ascending' : 'Descending',
onPressed: () => onSortChanged(field: sortField, asc: !ascending),
),
],
),
],
);
}
Widget build(BuildContext context) => SortChoiceRow<HistorySortField>(
options: _options,
selected: sortField,
ascending: ascending,
onChanged: onSortChanged,
);
}

View File

@ -170,29 +170,17 @@ String dateRangeLabel(DateTimeRange r) =>
'${r.start.toString().substring(0, 10)}'
' – ${r.end.toString().substring(0, 10)}';
/// Render a `YYYY-MM-DD` key as a short `Wed 12 Aug`-style label.
/// Render a `YYYY-MM-DD` key as numeric `DD.MM.YYYY` (no weekday or month
/// names: those were written words, and a date is a number).
///
/// Falls back to the raw key when it cannot be parsed, so a malformed date
/// shows something rather than throwing inside a list build.
String formatDay(String dateKey) {
try {
final d = DateTime.parse(dateKey);
const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const mo = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return '${wd[d.weekday - 1]} ${d.day} ${mo[d.month - 1]} ${d.year}';
final day = d.day.toString().padLeft(2, '0');
final month = d.month.toString().padLeft(2, '0');
return '$day.$month.${d.year}';
} on Exception {
return dateKey;
}

View File

@ -3,9 +3,14 @@
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
library;
import 'dart:async';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/source_icon.dart';
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
import 'package:flutter/material.dart';
@ -84,28 +89,44 @@ class DayHeaderTile extends StatelessWidget {
),
),
),
Text(
'${header.entryCount}'
' ${header.entryCount == 1 ? 'entry' : 'entries'}',
style: textTheme.bodySmall,
Icon(
Icons.restaurant,
size: 14,
color: colorScheme.onSurfaceVariant,
semanticLabel: 'entries',
),
const SizedBox(width: 3),
Text('${header.entryCount}', style: textTheme.bodySmall),
],
),
const SizedBox(height: 2),
Row(
children: [
Icon(
MacroKind.kcal.icon,
size: 14,
color: kcalColor,
semanticLabel: 'kcal',
),
const SizedBox(width: 3),
Text(
'${header.totalKcal.round()} / $goal kcal',
'${header.totalKcal.round()} / $goal',
style: textTheme.bodySmall?.copyWith(color: kcalColor),
),
const SizedBox(width: 12),
MacroFigure(
kind: MacroKind.protein,
value: '${header.totalProtein.round()}g',
),
const SizedBox(width: 8),
Text(
'P ${header.totalProtein.round()}g · '
'C ${header.totalCarbs.round()}g · '
'F ${header.totalFat.round()}g',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
MacroFigure(
kind: MacroKind.carbs,
value: '${header.totalCarbs.round()}g',
),
const SizedBox(width: 8),
MacroFigure(
kind: MacroKind.fat,
value: '${header.totalFat.round()}g',
),
],
),
@ -135,7 +156,13 @@ class EntryTile extends StatelessWidget {
return ListTile(
leading: const Icon(Icons.restaurant),
title: Text(entry.desc),
subtitle: Text('${entry.time} • ${entry.source}'),
subtitle: Row(
children: [
Flexible(child: Text(entry.time, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
SourceMark(entry.source),
],
),
// Delete needs a focusable control, not just a long-press. Long-press has
// no keyboard equivalent at all, so on the desktop build (a Chrome --app
// window) deleting an entry was pointer-only: unreachable by keyboard and
@ -144,12 +171,18 @@ class EntryTile extends StatelessWidget {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('${entry.kcal.toStringAsFixed(0)} kcal'),
MacroFigure(
kind: MacroKind.kcal,
value: entry.kcal.toStringAsFixed(0),
size: 14,
),
if (canDelete)
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete entry',
onPressed: () => _confirmDelete(context),
icon: const Icon(
Icons.delete_outline,
semanticLabel: 'Delete entry',
),
onPressed: () => unawaited(_delete(context)),
),
],
),
@ -157,35 +190,33 @@ class EntryTile extends StatelessWidget {
// Delete remains id-only to avoid ambiguous time+desc matches.
onTap: () => onEdit?.call(),
// Retained as a redundant pointer shortcut, never the only route.
onLongPress: canDelete ? () => _confirmDelete(context) : null,
onLongPress: canDelete ? () => unawaited(_delete(context)) : null,
);
}
Future<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete entry?'),
content: Text('Remove "${entry.desc}" from history?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
/// Deletes at once and offers an icon-only undo instead of asking first.
///
/// The snackbar is raised before the awaits because the reload that
/// follows rebuilds the list and may dispose this tile's context. Undo
/// writes the live copy back over the tombstone (same id), then reloads.
Future<void> _delete(BuildContext context) async {
showCueSnack(
context,
kind: CueKind.ok,
icon: Icons.delete_outline,
semanticLabel: 'Entry deleted',
onUndo: () => unawaited(_restore()),
);
if (confirmed == true) {
await LogStorageService.instance.deleteEntry(entry.id!);
await onDelete?.call();
}
await LogStorageService.instance.deleteEntry(entry.id!);
await onDelete?.call();
}
Future<void> _restore() async {
await LogStorageService.instance.updateEntry(
entry.copyWithDeleted(),
entry,
);
await onDelete?.call();
}
}

View File

@ -52,44 +52,44 @@ class LogMealActionsRow extends StatelessWidget {
final canFetch = canFetchDelivery ?? kuchniaFetchSupported;
return Row(
children: [
// Flexible, not Spacer-and-hope: `OutlinedButton.icon` lays its label
// out unbounded, so on a narrow phone the queue suffix renders wider
// than the row and is clipped without ever throwing an overflow. The
// Flexible caps the width and the FittedBox scales the text into it.
// Expanded so the delivery button takes the slack and pushes the log
// button right; the queue is a numeral next to the icon, so there is
// no wide label to clip. While fetching the icon becomes a spinner.
if (canFetch)
Expanded(
child: Tooltip(
message: "Load today's catering delivery",
child: OutlinedButton.icon(
onPressed: deliveryBusy ? null : onLoadDelivery,
icon: const Icon(Icons.restaurant),
label: FittedBox(
fit: BoxFit.scaleDown,
child: Text(_label, maxLines: 1),
),
child: OutlinedButton(
onPressed: deliveryBusy ? null : onLoadDelivery,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (deliveryBusy)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
const Icon(
Icons.restaurant,
semanticLabel: "Load today's catering delivery",
),
if (dishesQueued > 0) ...[
const SizedBox(width: AppSpacing.xs),
Text('$dishesQueued'),
],
],
),
),
),
// No Spacer: it is a flex child too, so it would split the free space
// with the Flexible above and re-create the clipping this guards
// against. The Flexible takes the slack and pushes the log button
// right on its own.
// No Spacer when the delivery button is present: it is a flex child
// too and would split the free space with the Expanded above.
if (!canFetch) const Spacer(),
const SizedBox(width: AppSpacing.sm),
Tooltip(
message: 'Log meal',
child: FilledButton(
onPressed: onLog,
child: const Icon(Icons.check_circle),
),
FilledButton(
onPressed: onLog,
child: const Icon(Icons.check_circle, semanticLabel: 'Log meal'),
),
],
);
}
String get _label {
if (deliveryBusy) return 'Loading…';
if (dishesQueued > 0) return "Today's delivery ($dishesQueued more to go)";
return "Today's delivery";
}
}

View File

@ -4,6 +4,8 @@
library;
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:flutter/material.dart';
/// Text controllers for one macro-entry row, owned by the calling screen so
@ -91,7 +93,8 @@ class MacroControllers {
}
}
/// A labeled row of number-entry fields for calories, macros, and the
/// A row of number-entry fields, each captioned by an icon: calories,
/// macros, and the
/// optional reference-weight-vs-eaten-weight split.
///
/// Layout mirrors the Python gate's macro section: the reference weight
@ -116,76 +119,59 @@ class MacroInputRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fields = [
_macroField(MacroKind.perGrams, controllers.perGrams),
_macroField(MacroKind.kcal, controllers.kcal),
_macroField(MacroKind.protein, controllers.protein),
_macroField(MacroKind.carbs, controllers.carbs),
_macroField(MacroKind.fat, controllers.fat),
_macroField(MacroKind.eaten, controllers.grams),
];
if (compact) {
return Row(
children: [
Expanded(child: _macroField('per', controllers.perGrams)),
const SizedBox(width: 4),
Expanded(child: _macroField('kcal', controllers.kcal)),
const SizedBox(width: 4),
Expanded(child: _macroField('P', controllers.protein)),
const SizedBox(width: 4),
Expanded(child: _macroField('C', controllers.carbs)),
const SizedBox(width: 4),
Expanded(child: _macroField('F', controllers.fat)),
const SizedBox(width: 4),
Expanded(
child: Tooltip(
message: "blank = same as 'per (g)'",
child: _macroField('eaten', controllers.grams),
),
),
for (final (i, field) in fields.indexed) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(child: field),
],
],
);
}
// Reference weight and kcal share a line, then the three macros, then the
// eaten weight: the same order the Python gate shows them in.
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// per-gram reference weight and kcal on the same line.
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
width: 72,
child: _macroField('per (g)', controllers.perGrams),
),
SizedBox(width: 96, child: fields[0]),
const SizedBox(width: 8),
Expanded(child: _macroField('kcal', controllers.kcal)),
Expanded(child: fields[1]),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _macroField('protein g', controllers.protein)),
const SizedBox(width: 8),
Expanded(child: _macroField('carbs g', controllers.carbs)),
const SizedBox(width: 8),
Expanded(child: _macroField('fat g', controllers.fat)),
for (final (i, field) in fields.sublist(2, 5).indexed) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: field),
],
],
),
const SizedBox(height: 8),
_macroField(
'eaten (g)',
controllers.grams,
helperText: "blank = same as 'per (g)'",
),
fields[5],
],
);
}
Widget _macroField(
String label,
TextEditingController controller, {
String? helperText,
}) {
return TextField(
Widget _macroField(MacroKind kind, TextEditingController controller) {
return IconField(
icon: kind.icon,
iconColor: kind.color,
semanticLabel: kind.semanticLabel(compact: compact),
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: label,
helperText: helperText,
isDense: true,
),
dense: true,
);
}
}

View File

@ -4,6 +4,7 @@ library;
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/ui/icon_field.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/meal_time_dropdown.dart';
import 'package:flutter/material.dart';
@ -61,15 +62,9 @@ class MealScheduleEditor extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Meal times', style: theme.textTheme.titleMedium),
const SizedBox(height: AppSpacing.xs),
Text(
'Checkpoints are spread evenly between your first and last meal.',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
MealTimeDropdown(
label: 'First meal',
icon: Icons.light_mode_outlined,
semanticLabel: 'First meal',
value: schedule.firstMinute,
minMinute: 0,
maxMinute: _firstMax,
@ -78,7 +73,8 @@ class MealScheduleEditor extends StatelessWidget {
),
const SizedBox(height: 12),
MealTimeDropdown(
label: 'Last meal',
icon: Icons.dark_mode_outlined,
semanticLabel: 'Last meal',
value: schedule.lastMinute,
// At least one grid step after the first meal: the shortest window
// that still holds two distinct checkpoints.
@ -89,21 +85,26 @@ class MealScheduleEditor extends StatelessWidget {
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppWidth.field),
child: InputDecorator(
decoration: const InputDecoration(labelText: 'Meals per day'),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: counts.contains(schedule.count)
? schedule.count
: counts.last,
isDense: true,
isExpanded: true,
items: [
for (final count in counts)
DropdownMenuItem(value: count, child: Text('$count')),
],
onChanged: (count) =>
count == null ? null : onChanged(_with(count: count)),
child: SpokenLabel(
semanticLabel: 'Meals per day',
child: InputDecorator(
decoration: const InputDecoration(
prefixIcon: ExcludeSemantics(child: Icon(Icons.restaurant)),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: counts.contains(schedule.count)
? schedule.count
: counts.last,
isDense: true,
isExpanded: true,
items: [
for (final count in counts)
DropdownMenuItem(value: count, child: Text('$count')),
],
onChanged: (count) =>
count == null ? null : onChanged(_with(count: count)),
),
),
),
),

View File

@ -26,7 +26,8 @@ import 'package:flutter/material.dart';
class MealTimeDropdown extends StatelessWidget {
/// Creates a [MealTimeDropdown].
const MealTimeDropdown({
required this.label,
required this.icon,
required this.semanticLabel,
required this.value,
required this.minMinute,
required this.maxMinute,
@ -35,8 +36,11 @@ class MealTimeDropdown extends StatelessWidget {
super.key,
});
/// The field label, e.g. "First meal".
final String label;
/// The painted caption glyph, e.g. a sun for the first meal.
final IconData icon;
/// The spoken caption, e.g. "First meal"; never painted.
final String semanticLabel;
/// The current minute of day.
final int value;
@ -81,7 +85,8 @@ class MealTimeDropdown extends StatelessWidget {
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: value ~/ 60, minute: value % 60),
helpText: label,
// Empty so the picker does not print its own heading over the clock.
helpText: '',
// The rest of the app writes times as HH:MM; an AM/PM dial here would
// be the one place that reads differently.
builder: (context, child) => MediaQuery(
@ -103,29 +108,37 @@ class MealTimeDropdown extends StatelessWidget {
spacing: AppSpacing.xs,
children: [
Expanded(
child: InputDecorator(
decoration: InputDecoration(labelText: label),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: value,
isDense: true,
isExpanded: true,
items: [
for (final minute in options)
DropdownMenuItem(
value: minute,
child: Text(slotLabel(minute)),
),
],
onChanged: (minute) =>
minute == null ? null : onChanged(minute),
child: Semantics(
label: semanticLabel,
container: true,
child: InputDecorator(
decoration: InputDecoration(
prefixIcon: ExcludeSemantics(child: Icon(icon)),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: value,
isDense: true,
isExpanded: true,
items: [
for (final minute in options)
DropdownMenuItem(
value: minute,
child: Text(slotLabel(minute)),
),
],
onChanged: (minute) =>
minute == null ? null : onChanged(minute),
),
),
),
),
),
IconButton(
icon: const Icon(Icons.schedule),
tooltip: 'Type or pick an exact time',
icon: const Icon(
Icons.schedule,
semanticLabel: 'Type or pick an exact time',
),
onPressed: () => unawaited(_pickExact(context)),
),
],

View File

@ -2,13 +2,15 @@
library;
import 'package:diet_guard_app/models/day_status.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// Displays the logging streak, adherence streak, and year-to-date tally.
///
/// Mirrors `_calendar_view.py`'s `streaks_text`/`ytd_text` formatting:
/// "Logging streak: N day(s) · Adherence streak: N day(s)" and
/// "This year: logged X/Y days · Z within budget".
/// Mirrors `_calendar_view.py`'s `streaks_text`/`ytd_text` data, as glyphs and
/// numerals: pencil + N (logging streak), flame + N (adherence streak),
/// calendar-check + X/Y (days logged of days elapsed), medal + Z (days within
/// budget).
class StreakSummaryRow extends StatelessWidget {
/// Creates a [StreakSummaryRow].
const StreakSummaryRow({
@ -27,27 +29,80 @@ class StreakSummaryRow extends StatelessWidget {
/// This year's logged/elapsed/adherent day counts.
final YtdTally tally;
static String _plural(int count) => count == 1 ? 'day' : 'days';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final status = theme.extension<AppStatusColors>() ?? AppStatusColors.dark;
return Column(
children: [
Text(
'Logging streak: $loggingStreak ${_plural(loggingStreak)} · '
'Adherence streak: $adherenceStreak ${_plural(adherenceStreak)}',
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall,
Wrap(
alignment: WrapAlignment.center,
spacing: AppSpacing.lg,
children: [
_Figure(
icon: Icons.edit_note,
color: theme.colorScheme.primary,
semanticLabel: 'Logging streak',
value: '$loggingStreak',
style: theme.textTheme.titleSmall,
),
_Figure(
icon: Icons.local_fire_department,
color: status.warning,
semanticLabel: 'Adherence streak',
value: '$adherenceStreak',
style: theme.textTheme.titleSmall,
),
],
),
const SizedBox(height: 4),
Text(
'This year: logged ${tally.loggedDays}/${tally.elapsedDays} days · '
'${tally.adherentDays} within budget',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
const SizedBox(height: AppSpacing.xs),
Wrap(
alignment: WrapAlignment.center,
spacing: AppSpacing.lg,
children: [
_Figure(
icon: Icons.event_available,
color: theme.colorScheme.onSurfaceVariant,
semanticLabel: 'Days logged this year',
value: '${tally.loggedDays}/${tally.elapsedDays}',
style: theme.textTheme.bodySmall,
),
_Figure(
icon: Icons.verified,
color: status.success,
semanticLabel: 'Days within budget',
value: '${tally.adherentDays}',
style: theme.textTheme.bodySmall,
),
],
),
],
);
}
}
class _Figure extends StatelessWidget {
const _Figure({
required this.icon,
required this.color,
required this.semanticLabel,
required this.value,
required this.style,
});
final IconData icon;
final Color color;
final String semanticLabel;
final String value;
final TextStyle? style;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 18, semanticLabel: semanticLabel),
const SizedBox(width: 4),
Text(value, style: style),
],
);
}

View File

@ -40,16 +40,22 @@ class SyncHealthBanner extends StatelessWidget {
border: Border.all(color: warning.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(8),
),
// A glyph and, once an hour or more has passed, the elapsed time as a
// numeral (`2d`). The sentence survives only as the spoken label.
child: Row(
children: [
Icon(Icons.cloud_off, size: 18, color: warning),
Icon(
Icons.cloud_off,
size: 18,
color: warning,
semanticLabel: message,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
if (status?.agoShort case final ago?)
Text(
ago,
style: theme.textTheme.bodySmall?.copyWith(color: warning),
),
),
],
),
);

View File

@ -3,6 +3,7 @@
library;
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/progress_motion.dart';
import 'package:flutter/material.dart';
@ -87,8 +88,6 @@ class TodayProgressCard extends StatelessWidget {
/// The values to display.
final TodayProgress progress;
static String _plural(int count) => count == 1 ? 'day' : 'days';
static String _grams(double value) => '${value.round()}g';
/// Whole numbers without decimals; fractions exactly, never rounded.
@ -98,6 +97,7 @@ class TodayProgressCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final status = theme.extension<AppStatusColors>() ?? AppStatusColors.dark;
final slot = progress.loggedSlotMinute;
final logged = progress.loggedKcal;
final from = progress.previousKcal ?? progress.consumedKcal;
@ -118,31 +118,55 @@ class TodayProgressCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (slot != null && logged != null) ...[
Text(
'✓ ${slotLabel(slot)} · ${_kcal(logged)} kcal',
style: theme.textTheme.labelMedium,
Row(
children: [
Icon(
Icons.check_circle,
size: 16,
color: status.success,
semanticLabel: 'logged',
),
const SizedBox(width: AppSpacing.xs),
Text(slotLabel(slot), style: theme.textTheme.labelMedium),
const SizedBox(width: AppSpacing.sm),
MacroFigure(kind: MacroKind.kcal, value: _kcal(logged)),
],
),
const SizedBox(height: AppSpacing.xs),
],
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,
Row(
children: [
MacroFigure(
kind: MacroKind.protein,
value: _grams(progress.proteinG),
),
const SizedBox(width: AppSpacing.md),
MacroFigure(
kind: MacroKind.carbs,
value: _grams(progress.carbsG),
),
const SizedBox(width: AppSpacing.md),
MacroFigure(kind: MacroKind.fat, value: _grams(progress.fatG)),
],
),
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,
);
},
builder: (context, value) => Row(
children: [
Icon(
Icons.local_fire_department,
size: 16,
color: status.warning,
semanticLabel: 'adherence streak',
),
const SizedBox(width: 3),
Text('${value.round()}', style: theme.textTheme.labelMedium),
],
),
),
],
),
@ -156,24 +180,49 @@ class TodayProgressCard extends StatelessWidget {
final colors = theme.colorScheme;
final remaining = progress.budgetKcal - consumed;
final over = remaining < 0;
return Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
final tint = over ? colors.error : colors.onSurface;
// Bar fill = share of the budget used; it turns red AND the right-hand
// glyph flips from a flag (room left) to a warning (over) past the budget.
final used = progress.budgetKcal > 0
? (consumed / progress.budgetKcal).clamp(0.0, 1.0)
: 1.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${consumed.round()} / ${progress.budgetKcal}',
style: theme.textTheme.titleMedium?.copyWith(
color: over ? colors.error : colors.onSurface,
),
Row(
children: [
Icon(
MacroKind.kcal.icon,
size: 18,
color: MacroKind.kcal.color,
semanticLabel: 'kcal',
),
const SizedBox(width: AppSpacing.xs),
Text(
'${consumed.round()} / ${progress.budgetKcal}',
style: theme.textTheme.titleMedium?.copyWith(color: tint),
),
const Spacer(),
Icon(
over ? Icons.warning_amber_rounded : Icons.flag_outlined,
size: 16,
color: over ? colors.error : colors.onSurfaceVariant,
semanticLabel: over ? 'over budget' : 'left',
),
const SizedBox(width: 3),
Text(
'${remaining.abs().round()}',
style: theme.textTheme.labelMedium?.copyWith(color: tint),
),
],
),
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,
),
const SizedBox(height: AppSpacing.xs),
LinearProgressIndicator(
value: used,
minHeight: 6,
borderRadius: BorderRadius.circular(3),
color: over ? colors.error : colors.primary,
backgroundColor: colors.surfaceContainerHighest,
),
],
);

View File

@ -157,11 +157,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
ref: "crdt_sync_dart-v0.12.1"
resolved-ref: "6641d85f0550ca60433fc8e0d13a3dafdbb664d6"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.12.0"
version: "0.12.1"
cross_file:
dependency: transitive
description:
@ -375,10 +375,10 @@ packages:
dependency: "direct main"
description:
name: fl_chart
sha256: "5ed69b8be550c6276c8f0bc25c09704fb061018ed1b1681867ad18fcf477e7b7"
sha256: "9f11b0b87a6b1843f5ca666ade4a62c08da1a6b7f9efeb91bb2c6f3fa6e31a9a"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.3.1"
flutter:
dependency: "direct main"
description: flutter
@ -544,10 +544,10 @@ packages:
dependency: "direct main"
description:
name: idb_shim
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b"
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
url: "https://pub.dev"
source: hosted
version: "2.11.2"
version: "2.11.3"
image:
dependency: transitive
description:
@ -824,10 +824,10 @@ packages:
dependency: transitive
description:
name: sembast
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a"
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
url: "https://pub.dev"
source: hosted
version: "3.10.0"
version: "3.10.1"
share_plus:
dependency: transitive
description:
@ -941,11 +941,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.6.1"
resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad
ref: "sync_settings_ui-v0.6.2"
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.6.1"
version: "0.6.2"
synchronized:
dependency: transitive
description:

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.138+1
version: 1.0.154+1
environment:
sdk: ^3.12.2
@ -12,7 +12,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.12.0
ref: crdt_sync_dart-v0.12.1
path: crdt_sync_dart
crypto: 3.0.8
cryptography: 2.9.0
@ -23,7 +23,7 @@ dependencies:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter
fl_chart: 1.3.0
fl_chart: 1.3.1
flutter:
sdk: flutter
flutter_local_notifications: 23.0.0
@ -44,7 +44,7 @@ dependencies:
path: google_account_token
health: 13.3.2
http: 1.6.0
idb_shim: 2.11.2
idb_shim: 2.11.3
path: 1.9.1
path_provider: 2.1.6
permission_handler: 13.0.2
@ -57,7 +57,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.6.1
ref: sync_settings_ui-v0.6.2
path: sync_settings_ui
url_launcher: 6.3.3
uuid: 4.6.0

View File

@ -0,0 +1,153 @@
// Guard: whatever the desktop wrapper answers with an error status reaches
// the Chrome --app window as a status code with an EMPTY body -- never an
// error sentence painted in place of the app. Terminal stderr lines (the
// launcher's own log) are not covered: they are terminal text and stay.
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/desktop/github_proxy.dart';
import 'package:diet_guard_app/desktop/wrapper_server.dart';
import 'package:diet_guard_app/services/desktop_wrapper.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:path/path.dart' as p;
/// Removes <title> and <style> tags, strips all remaining HTML tags, and
/// returns every [A-Za-z]{2,} match — i.e. all visible words the browser would
/// paint for a top-level navigation.
List<String> paintedWords(String html) {
var noTitle = html.replaceAll(RegExp(r'<title>.*?</title>', dotAll: true), '');
var noStyle = noTitle.replaceAll(RegExp(r'<style>.*?</style>', dotAll: true), '');
var noTags = noStyle.replaceAll(RegExp(r'<[^>]*>'), '');
return RegExp(r'[A-Za-z]{2,}').allMatches(noTags).map((m) => m.group(0)!).toList();
}
void main() {
late Directory tempDir;
late WrapperServer server;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_no_text_');
final webRoot = p.join(tempDir.path, 'web');
final dataDir = p.join(tempDir.path, 'data');
await Directory(webRoot).create(recursive: true);
File(p.join(webRoot, 'index.html')).writeAsStringSync('<html></html>');
// A FILE where the documents directory belongs: a POST then throws
// inside the handler, which is the wrapper's 500 path.
await Directory(dataDir).create(recursive: true);
File(p.join(dataDir, 'documents')).writeAsStringSync('');
server = WrapperServer(
webRoot: webRoot,
dataDir: dataDir,
gitHubProxy: GitHubProxy(
tokenPath: p.join(tempDir.path, 'sync_token'),
fallbackTokenPath: p.join(tempDir.path, 'fallback_token'),
httpClient: MockClient((_) async => http.Response('{}', 200)),
),
);
await server.start(0);
});
tearDown(() async {
await server.stop();
await tempDir.delete(recursive: true);
});
Uri url(String path) => Uri.parse('http://localhost:${server.port}$path');
// (method, path, expected status): every error route the wrapper owns.
final cases = <(String, String, int)>[
('GET', '/missing.js', HttpStatus.notFound),
('GET', '/github/nope', HttpStatus.notFound),
('GET', WrapperPaths.documents, HttpStatus.badRequest),
('PUT', '${WrapperPaths.documents}log.json', HttpStatus.methodNotAllowed),
('GET', '${WrapperPaths.documents}absent.json', HttpStatus.notFound),
('GET', kSyncAccountPath, HttpStatus.notFound),
(
'POST',
'${WrapperPaths.documents}log.json',
HttpStatus.internalServerError,
),
];
for (final (method, path, status) in cases) {
test('$method $path answers $status with an empty body', () async {
final response = await http.Response.fromStream(
await http.Client().send(http.Request(method, url(path))),
);
expect(response.statusCode, status);
expect(response.body, isEmpty, reason: 'nothing painted in the window');
});
test('$method $path loaded as a page shows only an icon', () async {
final request = http.Request(method, url(path))
..headers['sec-fetch-mode'] = 'navigate';
final response = await http.Response.fromStream(
await http.Client().send(request),
);
expect(response.statusCode, status);
expect(response.headers['content-type'], contains('text/html'));
expect(response.body, contains('<svg'));
expect(paintedWords(response.body), isEmpty);
});
}
group('proxy error responses', () {
late Directory tempDir;
late WrapperServer server;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_proxy_err_');
final webRoot = p.join(tempDir.path, 'web');
final dataDir = p.join(tempDir.path, 'data');
await Directory(webRoot).create(recursive: true);
File(p.join(webRoot, 'index.html')).writeAsStringSync('<html></html>');
await Directory(dataDir).create(recursive: true);
File(p.join(dataDir, 'documents')).writeAsStringSync('');
final tokenPath = p.join(tempDir.path, 'sync_token');
await File(tokenPath).writeAsString('t');
server = WrapperServer(
webRoot: webRoot,
dataDir: dataDir,
gitHubProxy: GitHubProxy(
tokenPath: tokenPath,
fallbackTokenPath: p.join(tempDir.path, 'fallback_token'),
httpClient: MockClient((_) async =>
http.Response('{"message": "Bad credentials"}', 401)),
),
);
await server.start(0);
});
tearDown(() async {
await server.stop();
await tempDir.delete(recursive: true);
});
Uri url(String path) => Uri.parse('http://localhost:${server.port}$path');
test('a proxy error loaded as a page shows only an icon', () async {
final request = http.Request('GET', url('/github/api/repos/o/r/contents/x'))
..headers['sec-fetch-mode'] = 'navigate';
final response = await http.Response.fromStream(
await http.Client().send(request),
);
expect(response.statusCode, HttpStatus.unauthorized);
expect(response.headers['content-type'], contains('text/html'));
expect(response.body, contains('<svg'));
expect(paintedWords(response.body), isEmpty);
});
test('the app fetch still gets the proxy error body', () async {
final response = await http.Response.fromStream(
await http.Client().send(http.Request(
'GET',
url('/github/api/repos/o/r/contents/x'),
)),
);
expect(response.statusCode, HttpStatus.unauthorized);
expect(response.body, contains('Bad credentials'));
});
});
}

View File

@ -1,10 +1,12 @@
/// The "Fill all with catering" row: label per state, Cancel only when armed.
/// The "Fill all with catering" row: glyph per state, Cancel only when armed.
library;
import 'package:diet_guard_app/widgets/fill_all_row.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'label_finders.dart';
Future<void> _pump(
WidgetTester tester, {
bool canFetch = true,
@ -31,26 +33,30 @@ void main() {
await _pump(t, canFetch: false, armed: 3);
expect(find.byType(OutlinedButton), findsNothing);
expect(find.text(fillAllLabel), findsNothing);
expect(find.text('Cancel'), findsNothing);
expect(byIconLabel(fillAllLabel), findsNothing);
expect(byIconLabel('Cancel'), findsNothing);
});
testWidgets('resting: the fill label and no Cancel', (t) async {
testWidgets('resting: the fill glyph and no Cancel', (t) async {
await _pump(t);
expect(find.text('🍱 Fill all with catering'), findsOneWidget);
expect(find.text('Cancel'), findsNothing);
expect(byIconLabel(fillAllLabel), findsOneWidget);
expect(find.byIcon(Icons.playlist_add_check), findsOneWidget);
expect(byIconLabel('Cancel'), findsNothing);
});
testWidgets('armed: Confirm (N) and a Cancel button', (t) async {
testWidgets('armed: double check with the count, and a Cancel button', (
t,
) async {
await _pump(t, armed: 3);
expect(find.text('✓ Confirm (3)'), findsOneWidget);
expect(find.text(fillAllLabel), findsNothing);
expect(find.text('Cancel'), findsOneWidget);
expect(byIconLabel('Confirm'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(byIconLabel(fillAllLabel), findsNothing);
expect(byIconLabel('Cancel'), findsOneWidget);
});
testWidgets('busy: Working… and both controls disabled', (t) async {
testWidgets('busy: spinner and both controls disabled', (t) async {
var fills = 0;
var cancels = 0;
await _pump(
@ -61,15 +67,15 @@ void main() {
onCancel: () => cancels++,
);
expect(find.text('Working…'), findsOneWidget);
expect(find.text('✓ Confirm (3)'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(byIconLabel('Confirm'), findsNothing);
expect(
t.widget<OutlinedButton>(find.byType(OutlinedButton)).onPressed,
isNull,
);
expect(t.widget<TextButton>(find.byType(TextButton)).onPressed, isNull);
expect(t.widget<IconButton>(find.byType(IconButton)).onPressed, isNull);
await t.tap(find.byType(OutlinedButton), warnIfMissed: false);
await t.tap(find.byType(TextButton), warnIfMissed: false);
await t.tap(find.byType(IconButton), warnIfMissed: false);
expect(fills + cancels, 0);
});
@ -83,8 +89,8 @@ void main() {
onCancel: () => cancels++,
);
await t.tap(find.text('✓ Confirm (2)'));
await t.tap(find.text('Cancel'));
await t.tap(byIconLabel('Confirm'));
await t.tap(byIconLabel('Cancel'));
expect([fills, cancels], [1, 1]);
});

View File

@ -22,6 +22,7 @@ import 'package:diet_guard_app/widgets/day_status_calendar.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'label_finders.dart';
/// Landscape and *short* — the supported floor. Phone-portrait sizes, which is
/// all the existing suite pinned, exercise neither constraint.
@ -176,7 +177,7 @@ void main() {
// Delete used to be bound only to onLongPress, which has no keyboard
// equivalent whatsoever.
expect(
find.byTooltip('Delete entry'),
byIconLabel('Delete entry'),
findsOneWidget,
reason: 'delete needs a tab-reachable control, not long-press only',
);
@ -207,7 +208,7 @@ void main() {
// Delete stays id-only to avoid ambiguous time+desc matches, so the
// control must be absent rather than present-and-failing.
expect(find.byTooltip('Delete entry'), findsNothing);
expect(byIconLabel('Delete entry'), findsNothing);
});
});
}

View File

@ -0,0 +1,46 @@
/// Finders for text-free controls: they match the spoken label that icons and
/// [Semantics] carry, so tests keep addressing widgets by name without any
/// visible text.
library;
import 'package:diet_guard_app/ui/cues.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Finds an [Icon] with this `semanticLabel`.
Finder byIconLabel(String label) =>
find.byWidgetPredicate((w) => w is Icon && w.semanticLabel == label);
/// Finds a [Semantics] node with this label.
Finder bySemLabel(String label) => find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == label,
);
/// Finds the [TextField] captioned [label] (an [IconField] or a bare
/// [Semantics] wrapper).
Finder fieldByLabel(String label) =>
find.descendant(of: bySemLabel(label), matching: find.byType(TextField));
/// Finds the tappable control whose icon or semantics carries [label].
Finder controlByLabel(String label) => find
.ancestor(
of: byIconLabel(label),
matching: find.byWidgetPredicate(
(w) => w is IconButton || w is ButtonStyleButton || w is InkResponse,
),
)
.first;
/// Finds a [StatusCue] of [kind].
Finder cueOfKind(CueKind kind) =>
find.byWidgetPredicate((w) => w is StatusCue && w.kind == kind);
/// Finds a [StatusCue] whose spoken label is [label].
Finder cueLabelled(String label) =>
find.byWidgetPredicate((w) => w is StatusCue && w.semanticLabel == label);
/// Finds the chip ([FilterChip] or [ChoiceChip]) whose icon carries [label].
Finder chipByLabel(String label) => find.ancestor(
of: byIconLabel(label),
matching: find.byWidgetPredicate((w) => w is FilterChip || w is ChoiceChip),
);

View File

@ -0,0 +1,90 @@
// Guard: the Body screen, every tile sheet and the activity-method chooser
// paint no words (only numbers, unit symbols, MET and workout labels).
// Workout labels are content only for manual workouts (source == 'manual').
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'label_finders.dart';
import 'no_words_support.dart';
import 'screens/body_screen_support.dart';
/// Workout labels the PC published (content, see `seedPerson`), plus the
/// proper names of the BMI / ideal-weight / BMR formulas that label their
/// figures (DOUBT rows in keep.md: kept from the first pilot, outside the
/// activity sheet). Activity formulas (ACSM, Per-km) are NOT here: that sheet
/// and its chooser show glyphs only. Only manual workouts carry human content.
const Set<String> _workouts = {
'walk to the shop',
'Harris-Benedict (revised 1984)',
'Peterson (BMI 22)',
'Mifflin-St Jeor',
'Katch-McArdle',
'Cunningham',
'BMI Prime',
'Trefethen',
'Devine',
'Robinson',
'Miller',
};
void main() {
late MemoryDocStore store;
setUp(() async {
store = MemoryDocStore();
await setUpBodyServices(store);
});
tearDown(tearDownBodyServices);
test('the checker flags an injected word (canary)', () {
expect(wordsIn('70.0 kg'), isEmpty);
expect(wordsIn('265 MET'), isEmpty);
expect(wordsIn('Measured (ACSM)'), isNotEmpty);
expect(wordsIn('walk to the shop 4 km', content: _workouts), ['km']);
expect(wordsIn('walk to the shop', content: _workouts), isEmpty);
});
testWidgets('canary: a painted word fails the guard', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Text('Activity')));
expect(() => expectNoWords(tester), throwsA(isA<TestFailure>()));
});
for (final seeded in [false, true]) {
testWidgets('body tiles and sheets paint no words (seeded: $seeded)', (
tester,
) async {
if (seeded) await seedPerson(store);
await pumpBody(tester);
final found = wordyStrings(
tester,
content: _workouts,
minStrings: 5,
where: 'tiles',
);
for (final title in tileTitles(tester)) {
await openTile(tester, title);
found.addAll(wordyStrings(tester, content: _workouts, where: title));
await closeSheet(tester);
}
expect(found, isEmpty);
});
}
testWidgets('the activity-method chooser paints no words', (tester) async {
await seedPerson(store);
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
await tester.tap(bySemLabel('Moderate').first);
await tester.pumpAndSettle();
expectNoWords(
tester,
content: _workouts,
minStrings: 8,
where: 'activity chooser',
);
await tester.tap(bySemLabel('Measured (ACSM)').last);
await tester.pumpAndSettle();
expectNoWords(tester, content: _workouts, where: 'measured goal');
});
}

View File

@ -0,0 +1,77 @@
// Guard: a catering fetch / fill reports with a glyph and a dish count; its
// sentence is only the spoken label.
import 'package:diet_guard_app/screens/log_meal_fill_mixin.dart';
import 'package:diet_guard_app/services/kuchnia_spread.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'label_finders.dart';
import 'no_words_support.dart';
void main() {
group('fillCue', () {
test('nothing to fill paints a warning and 0', () {
final cue = fillCue((
message: 'No catering delivery today.',
written: const <SlottedDish>[],
), armed: 0);
expect(cue.kind, CueKind.warn);
expect(cue.count, 0);
expect(cue.label, 'No catering delivery today.');
});
test('a proposal paints its armed count', () {
final cue = fillCue((
message: 'Will log 3',
written: const <SlottedDish>[],
), armed: 3);
expect((cue.kind, cue.count), (CueKind.info, 3));
});
});
testWidgets('a counted cue paints only the glyph and the number', (
tester,
) async {
final state = CueState.next(
null,
CueKind.ok,
'Logged 4 catering dishes.',
4,
);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: state.widget)));
expect(find.text('4'), findsOneWidget);
expect(cueLabelled('Logged 4 catering dishes.'), findsOneWidget);
expectNoWords(tester, minStrings: 2, where: 'fill cue');
});
testWidgets('the delivery snackbar paints the dish glyph and 0', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showCueSnack(
context,
kind: CueKind.warn,
semanticLabel: 'No delivery today.',
icon: Icons.restaurant,
count: 0,
),
child: const Icon(Icons.local_shipping),
),
),
),
),
);
await tester.tap(find.byType(TextButton));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byIcon(Icons.restaurant), findsOneWidget);
expect(find.text('0'), findsOneWidget);
expectNoWords(tester, minStrings: 3, where: 'delivery snack');
});
}

View File

@ -0,0 +1,103 @@
// Guard: the log-meal, history, calendar and settings screens paint no
// words; only the logged food name (content), numbers and unit symbols.
//
// Date/time pickers are framework chrome and are not opened here: they are a
// follow-up task (custom icon pickers), listed in the rework report.
import 'dart:io';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/screens/calendar_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
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:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fake_secure_storage.dart';
import 'label_finders.dart';
import 'no_words_support.dart';
/// The one meal logged in setUp: user content, allowed.
const _food = 'porridge';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_no_words_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
await MealScheduleService.initForTesting(FileDocumentStore(tempDir));
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
MealScheduleService.resetForTesting();
await tempDir.delete(recursive: true);
});
Future<void> show(WidgetTester tester, Widget screen) async {
tester.view.physicalSize = const Size(800, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: screen));
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
Future<void> logOne() => LogStorageService.instance.logMeal(
_food,
const Nutrition(
kcal: 350,
proteinG: 12,
carbsG: 50,
fatG: 8,
grams: 300,
source: 'manual',
),
);
final screens = <String, Widget Function()>{
'log meal': LogMealScreen.new,
'history': HistoryScreen.new,
'calendar': CalendarScreen.new,
'settings': SettingsScreen.new,
};
for (final MapEntry(key: name, value: build) in screens.entries) {
testWidgets('$name paints no words', (tester) async {
await tester.runAsync(() async {
await logOne();
await show(tester, build());
expectNoWords(tester, content: {_food}, minStrings: 3, where: name);
});
});
}
testWidgets('a rejected log paints a cue, not a sentence', (tester) async {
await tester.runAsync(() async {
await show(tester, const LogMealScreen());
final log = controlByLabel('Log meal');
await tester.ensureVisible(log);
await tester.tap(log);
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pump(const Duration(seconds: 1));
expect(cueLabelled('Type what you ate first.'), findsOneWidget);
expectNoWords(tester, minStrings: 3, where: 'rejected log');
});
});
}

View File

@ -0,0 +1,69 @@
/// The "no words" guard: collects every string the current frame paints (all
/// [RichText], which every [Text] builds, plus [Tooltip] messages, which paint
/// on long-press) and reports the ones that hold a word.
///
/// A word is two or more letters in a row. Allowed without listing: numbers,
/// punctuation, single letters, unit symbols next to a figure ([unitTokens])
/// and the method acronyms kept as DOUBT rows (`MET`). Content (food names,
/// workout labels, typed values) is passed per test as [content].
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Unit symbols and method acronyms that may sit next to a number.
const Set<String> unitTokens = {'kcal', 'kg', 'cm', 'lb', 'st', 'MET'};
final RegExp _word = RegExp('[A-Za-z]{2,}');
/// Every painted string in the current tree (one per [RichText] / tooltip).
List<String> paintedStrings(WidgetTester tester) => [
for (final w in tester.widgetList<RichText>(find.byType(RichText)))
w.text.toPlainText(includePlaceholders: false),
for (final w in tester.widgetList<Tooltip>(find.byType(Tooltip))) ?w.message,
];
/// The words in [text] that are neither units nor part of [content].
List<String> wordsIn(String text, {Set<String> content = const {}}) {
var rest = text;
for (final c in content) {
rest = rest.replaceAll(c, ' ');
}
return [
for (final m in _word.allMatches(rest))
if (!unitTokens.contains(m.group(0))) m.group(0)!,
];
}
/// The painted strings of the current frame that hold a word, prefixed with
/// [where]. Fails when fewer than [minStrings] strings were found (an empty
/// tree must not pass the guard).
List<String> wordyStrings(
WidgetTester tester, {
Set<String> content = const {},
int minStrings = 1,
String where = '',
}) {
final strings = paintedStrings(tester);
expect(
strings.length,
greaterThanOrEqualTo(minStrings),
reason: '$where: guard saw too few painted strings (fail closed)',
);
return [
for (final s in strings)
if (wordsIn(s, content: content).isNotEmpty) '$where: $s',
];
}
/// Fails when a painted string of the current frame holds a word.
void expectNoWords(
WidgetTester tester, {
Set<String> content = const {},
int minStrings = 1,
String where = '',
}) => expect(
wordyStrings(tester, content: content, minStrings: minStrings, where: where),
isEmpty,
reason: '$where: painted words',
);

View File

@ -2,9 +2,11 @@ import 'package:diet_guard_app/models/body_document.dart';
import 'package:diet_guard_app/services/body_energy.dart';
import 'package:diet_guard_app/services/body_prefs.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import 'body_screen_support.dart';
void main() {
@ -22,10 +24,12 @@ void main() {
await seedPerson(store);
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
// Each mark of the goal row is spoken as its value; so is its picker
// option, hence `.last` for the option (the row stays mounted behind).
Future<void> choose(String word, String option) async {
await tester.tap(find.text(word).first);
await tester.tap(bySemLabel(word).first);
await tester.pumpAndSettle();
await tester.tap(find.text(option).last);
await tester.tap(bySemLabel(option).last);
await tester.pumpAndSettle();
}
@ -50,24 +54,25 @@ void main() {
);
// A typed amount; junk is refused and the dialog stays open.
final other = find.widgetWithText(TextField, 'Other amount');
final other = fieldByLabel('Other amount');
await tester.tap(find.text('1'));
await tester.pumpAndSettle();
await tester.enterText(other, '');
await tester.tap(find.text('OK'));
await tester.tap(byIconLabel('OK'));
await tester.pumpAndSettle();
expect(find.text('OK'), findsOneWidget);
expect(byIconLabel('OK'), findsOneWidget);
expect(cueOfKind(CueKind.error), findsOneWidget);
await tester.enterText(other, '2,5');
await tester.tap(find.text('OK'));
await tester.tap(byIconLabel('OK'));
await tester.pumpAndSettle();
expect(savedGoal(BodyService.instance).amount, 2.5);
// Cancel changes nothing; neither does dismissing a sheet.
await tester.tap(find.text('2.5'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.tap(byIconLabel('Cancel'));
await tester.pumpAndSettle();
await tester.tap(find.text('gain'));
await tester.tap(bySemLabel('gain').first);
await tester.pumpAndSettle();
await tester.tapAt(const Offset(5, 5));
await tester.pumpAndSettle();
@ -86,16 +91,19 @@ void main() {
await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET'));
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
expect(find.textContaining('no workouts published yet'), findsOneWidget);
await tester.tap(find.text('Measured (MET)'));
expect(
cueLabelled('no workouts published yet (the PC publishes them).'),
findsOneWidget,
);
await tester.tap(bySemLabel('Measured (MET)').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Sedentary').last);
await tester.tap(bySemLabel('Sedentary').last);
await tester.pumpAndSettle();
await tester.tap(find.text('1'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Other amount'), '3');
await tester.tap(find.text('OK'));
await tester.enterText(fieldByLabel('Other amount'), '3');
await tester.tap(byIconLabel('OK'));
await tester.pumpAndSettle();
expect(find.textContaining('below the 1200 kcal floor'), findsOneWidget);
expect(cueLabelled('That is below the 1200 kcal floor.'), findsOneWidget);
});
}

View File

@ -1,11 +1,11 @@
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/services/health_steps.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'body_screen_support.dart';
import '../label_finders.dart';
import '../services/health_steps_test.dart' show FakeStepsSource;
import 'body_screen_support.dart';
void main() {
final original = stepsSource;
@ -36,14 +36,14 @@ void main() {
expect(fake.lastRead, isNull, reason: 'never reads before permission');
await openTile(tester, 'Activity');
await tester.tap(find.text('Connect Health Connect steps'));
await tester.tap(byIconLabel('Connect Health Connect steps'));
await tester.pumpAndSettle();
expect(find.text('Connect Health Connect steps'), findsOneWidget);
expect(byIconLabel('Connect Health Connect steps'), findsOneWidget);
fake.grantOnRequest = true;
await tester.tap(find.text('Connect Health Connect steps'));
await tester.tap(byIconLabel('Connect Health Connect steps'));
await tester.pumpAndSettle();
expect(find.text('Connect Health Connect steps'), findsNothing);
expect(byIconLabel('Connect Health Connect steps'), findsNothing);
expect(
BodyService.instance.document.steps[localDateKey(yesterday)]!.n,
6500,
@ -56,13 +56,13 @@ void main() {
await pumpBody(tester);
await openTile(tester, 'Activity');
expect(fake.lastRead, isNotNull);
expect(find.text('Connect Health Connect steps'), findsNothing);
expect(byIconLabel('Connect Health Connect steps'), findsNothing);
});
testWidgets('a Health Connect error just hides nothing', (tester) async {
stepsSource = FakeStepsSource()..failWith = StateError('gone');
await pumpBody(tester);
await openTile(tester, 'Activity');
expect(find.text('Connect Health Connect steps'), findsOneWidget);
expect(byIconLabel('Connect Health Connect steps'), findsOneWidget);
});
}

View File

@ -11,6 +11,7 @@ import 'package:diet_guard_app/widgets/body/body_tile_grid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import '../services/body_test_support.dart';
export '../services/body_test_support.dart';
@ -69,16 +70,24 @@ Future<void> seedPerson(MemoryDocStore store, {Object? goal}) async {
'kind': 'run',
'min': 60,
'km': 10,
'label': 'Evening run',
'label': 'Running 10.0 km',
'src': 'runnerup',
},
{
'day': yesterdayKey(),
'kind': 'strength',
'min': 80,
'label': 'StrongLifts A',
'label': 'StrongLifts workout A (80 min, all succeeded)',
'src': 'screen-locker',
},
{
'day': yesterdayKey(),
'kind': 'walk',
'min': 30,
'km': 2,
'label': 'walk to the shop',
'src': 'manual',
},
],
},
});
@ -89,11 +98,11 @@ Future<void> seedPerson(MemoryDocStore store, {Object? goal}) async {
double topOf(WidgetTester tester, Finder finder) =>
tester.getTopLeft(finder.first).dy;
/// The overview tile titled [title].
/// The overview tile spoken as [title].
Finder tile(String title) =>
find.descendant(of: find.byType(BodyTileGrid), matching: find.text(title));
find.descendant(of: find.byType(BodyTileGrid), matching: bySemLabel(title));
/// Opens the sheet behind the tile titled [title].
/// Opens the sheet behind the tile spoken as [title].
Future<void> openTile(WidgetTester tester, String title) async {
await tester.tap(tile(title));
await tester.pumpAndSettle();
@ -109,9 +118,9 @@ Future<void> closeSheet(WidgetTester tester) async {
Finder inSheet(Finder finder) =>
find.descendant(of: find.byType(BottomSheet), matching: finder);
/// Titles of the overview tiles, in display order.
/// Spoken names of the overview tiles, in display order.
List<String> tileTitles(WidgetTester tester) => tester
.widget<BodyTileGrid>(find.byType(BodyTileGrid))
.tiles
.map((t) => t.title)
.map((t) => t.semanticLabel)
.toList();

View File

@ -1,9 +1,11 @@
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/screens/body_screen.dart';
import 'package:diet_guard_app/services/body_service.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import 'body_screen_support.dart';
void main() {
@ -20,18 +22,30 @@ void main() {
) async {
await pumpBody(tester);
expect(tileTitles(tester).first, 'Profile');
expect(find.textContaining('Age unknown'), findsOneWidget);
// Unknown age: the profile tile carries no number and no cake.
expect(
find.descendant(of: tile('Profile'), matching: find.byIcon(Icons.cake)),
findsNothing,
);
await openTile(tester, 'Daily calorie target');
expect(find.textContaining('Set your profile and enter'), findsOneWidget);
// Both inputs missing: one warning glyph each (profile, weight).
expect(cueOfKind(CueKind.warn), findsNWidgets(2));
expect(
cueLabelled(bodyMissingNote(BodyService.instance)),
findsNWidgets(2),
);
await closeSheet(tester);
await openTile(tester, 'Activity');
expect(find.textContaining('No activity data yet'), findsOneWidget);
expect(
cueLabelled('No activity data yet -- published by the PC.'),
findsOneWidget,
);
await closeSheet(tester);
await openTile(tester, 'Weight');
expect(find.text('Need two readings for a graph.'), findsOneWidget);
expect(byIconLabel('Need two readings for a graph.'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Body fat');
expect(find.text('Need two readings for a graph.'), findsOneWidget);
expect(byIconLabel('Need two readings for a graph.'), findsOneWidget);
});
testWidgets('a weight without a profile asks for the profile', (
@ -40,7 +54,12 @@ void main() {
await BodyService.instance.setWeight('2026-09-20', 80);
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
expect(find.textContaining('Set your profile (birth'), findsOneWidget);
expect(
cueLabelled(
'Set your profile (birth date, height, sex) to see the numbers.',
),
findsOneWidget,
);
});
testWidgets('a profile without a weight asks for the weight', (tester) async {
@ -51,7 +70,10 @@ void main() {
);
await pumpBody(tester);
await openTile(tester, 'Daily calorie target');
expect(find.text('Enter your weight to see the numbers.'), findsOneWidget);
expect(
cueLabelled('Enter your weight to see the numbers.'),
findsOneWidget,
);
});
testWidgets('a full profile: goal first, profile then body fat last', (
@ -68,44 +90,59 @@ void main() {
);
expect(titles.last, 'Body fat');
// Headline numbers on the tiles themselves.
expect(find.text('2209 kcal'), findsOneWidget);
expect(
find.descendant(
of: tile('Daily calorie target'),
matching: find.text('2209'),
),
findsOneWidget,
);
expect(find.text('80.0 kg'), findsOneWidget);
expect(find.text('24.7'), findsOneWidget);
expect(find.textContaining('Age 30'), findsOneWidget);
expect(
find.descendant(of: tile('Profile'), matching: find.text('30')),
findsOneWidget,
);
// 80 kg, 180 cm, 30 y, male, lose 0.5 kg/week on Moderate:
// 1780 x 1.55 - 550 = 2209.
await openTile(tester, 'Daily calorie target');
expect(find.text('2209'), findsOneWidget);
expect(
find.textContaining('Maintenance 2759 kcal (Mifflin-St Jeor'),
findsOneWidget,
);
expect(inSheet(find.text('2209')), findsOneWidget);
expect(inSheet(find.text('2759')), findsOneWidget);
expect(inSheet(find.text('Mifflin-St Jeor')), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'BMI');
expect(inSheet(find.text('24.7')), findsOneWidget);
expect(find.text('59.9-80.7 kg'), findsOneWidget);
expect(byIconLabel('normal'), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Ideal weight');
expect(find.text('Average of all'), findsOneWidget);
expect(byIconLabel('Average of all'), findsOneWidget);
expect(find.textContaining('Broca'), findsNothing);
await closeSheet(tester);
await openTile(tester, 'Resting metabolism (BMR)');
expect(inSheet(find.text('1780 kcal')), findsOneWidget);
expect(find.text('n/a -- log a body-fat %'), findsNWidgets(2));
expect(inSheet(find.text('1780')), findsOneWidget);
expect(byIconLabel('n/a -- log a body-fat %'), findsNWidgets(2));
await closeSheet(tester);
// 5000 steps over baseline x 0.747 m x 0.5 x 80 kg = 149.4 kcal, and
// all the data is from yesterday, so the window is that one day.
await openTile(tester, 'Activity');
expect(find.text('9000 ${yesterdayKey()}'), findsOneWidget);
expect(inSheet(find.text('149')), findsOneWidget);
expect(
find.textContaining('Steps outside workouts: 9000 on ${yesterdayKey()}'),
byIconLabel('Mean daily exercise, last 1 full days:'),
findsOneWidget,
);
expect(find.textContaining('add 149 kcal/day'), findsOneWidget);
expect(find.text('Mean daily exercise, last 1 full days:'), findsOneWidget);
expect(find.textContaining('Evening run'), findsOneWidget);
expect(find.textContaining('StrongLifts A'), findsOneWidget);
expect(find.text('Connect Health Connect steps'), findsNothing);
expect(
byIconLabel('Running 10.0 km'),
findsOneWidget,
);
expect(
byIconLabel('StrongLifts workout A (80 min, all succeeded)'),
findsOneWidget,
);
expect(find.textContaining('walk to the shop'), findsOneWidget);
expect(byIconLabel('Connect Health Connect steps'), findsNothing);
});
testWidgets('a body fat switches the target to Katch-McArdle', (
@ -114,24 +151,25 @@ void main() {
await seedPerson(store);
await pumpBody(tester);
await openTile(tester, 'Body fat');
final field = find.widgetWithText(TextField, 'Body fat (%)');
final field = fieldByLabel('Body fat (%)');
await tester.enterText(field, '99');
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await tester.pumpAndSettle();
expect(find.text('Body fat must be 3-70 %.'), findsOneWidget);
expect(cueLabelled('Body fat must be 3-70 %.'), findsOneWidget);
expect(cueOfKind(CueKind.error), findsOneWidget);
await tester.enterText(field, '18,5');
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await tester.pumpAndSettle();
expect(BodyService.instance.latestBodyFat?.$2, 18.5);
await closeSheet(tester);
expect(find.text('18.5 %'), findsOneWidget);
await openTile(tester, 'Daily calorie target');
expect(find.textContaining('(Katch-McArdle resting'), findsOneWidget);
expect(find.text('2206'), findsOneWidget);
expect(inSheet(find.text('Katch-McArdle')), findsOneWidget);
expect(inSheet(find.text('2206')), findsOneWidget);
await closeSheet(tester);
await openTile(tester, 'Resting metabolism (BMR)');
expect(inSheet(find.text('1778 kcal')), findsOneWidget);
expect(inSheet(find.text('1778')), findsOneWidget);
});
testWidgets('saving and deleting a weight updates the numbers', (
@ -140,14 +178,14 @@ void main() {
await seedPerson(store);
await pumpBody(tester);
await openTile(tester, 'Weight');
final field = find.widgetWithText(TextField, 'Weight (kg)');
final field = fieldByLabel('Weight (kg)');
await tester.enterText(field, '5');
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await tester.pumpAndSettle();
expect(find.text('Weight must be 20-400 kg.'), findsOneWidget);
expect(cueLabelled('Weight must be 20-400 kg.'), findsOneWidget);
await tester.enterText(field, '90,5');
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await tester.pumpAndSettle();
final today = localDateKey(DateTime.now());
expect(BodyService.instance.latestWeight, (today, 90.5));
@ -155,27 +193,32 @@ void main() {
expect(find.text('27.9'), findsOneWidget); // 90.5 / 1.8^2 on the BMI tile
await openTile(tester, 'Weight');
await tester.tap(find.byTooltip('Delete this day').first);
await tester.tap(byIconLabel('Delete this day').first);
await tester.pumpAndSettle();
expect(BodyService.instance.latestWeight?.$1, yesterdayKey());
// The undo arrow brings the deleted day back, value and all.
await tester.tap(byIconLabel('Undo'));
await tester.pumpAndSettle();
expect(BodyService.instance.latestWeight, (today, 90.5));
});
testWidgets('the profile form validates height and saves', (tester) async {
await pumpBody(tester);
await openTile(tester, 'Profile');
final field = find.widgetWithText(TextField, 'Height (cm)');
final field = fieldByLabel('Height (cm)');
await tester.enterText(field, '9');
await tester.tap(find.text('Save profile'));
await tester.tap(byIconLabel('Save profile'));
await tester.pumpAndSettle();
expect(find.text('Height must be 50-260 cm.'), findsOneWidget);
expect(cueLabelled('Height must be 50-260 cm.'), findsOneWidget);
await tester.enterText(field, '172.5');
await tester.tap(find.text('Female'));
await tester.tap(find.text('Save profile'));
await tester.tap(byIconLabel('Female'));
await tester.tap(byIconLabel('Save profile'));
await tester.pumpAndSettle();
expect(BodyService.instance.heightCm, 172.5);
expect(BodyService.instance.sex, 'f');
expect(find.text('Saved.'), findsOneWidget);
expect(cueLabelled('Saved.'), findsOneWidget);
});
test('kcalByDay of an empty log is empty', () {

View File

@ -8,23 +8,10 @@ import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
const _monthNames = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
String _label(DateTime d) => '${_monthNames[d.month - 1]} ${d.year}';
String _label(DateTime d) =>
'${d.month.toString().padLeft(2, '0')} · ${d.year}';
void main() {
late Directory tempDir;
@ -33,9 +20,7 @@ void main() {
tempDir = await Directory.systemTemp.createTemp('diet_guard_calendar_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
});
tearDown(() async {
@ -70,7 +55,7 @@ void main() {
'button', (tester) async {
await pumpScreen(tester);
expect(find.widgetWithText(TextField, '2200'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Edit'), findsOneWidget);
expect(controlByLabel('Edit'), findsOneWidget);
final field = tester.widget<TextField>(find.byType(TextField));
expect(field.enabled, isFalse);
});
@ -79,10 +64,10 @@ void main() {
tester,
) async {
await pumpScreen(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Edit'));
await tester.tap(controlByLabel('Edit'));
await tester.pump();
expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget);
expect(controlByLabel('Save'), findsOneWidget);
final field = tester.widget<TextField>(find.byType(TextField));
expect(field.enabled, isTrue);
});
@ -91,27 +76,27 @@ void main() {
tester,
) async {
await pumpScreen(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Edit'));
await tester.tap(controlByLabel('Edit'));
await tester.pump();
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await tester.tap(controlByLabel('Save'));
await tester.pump();
expect(find.text('Enter a whole number of kcal.'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget);
expect(cueLabelled('Enter a whole number of kcal.'), findsOneWidget);
expect(controlByLabel('Save'), findsOneWidget);
});
testWidgets('a zero value is rejected and editing stays open', (
tester,
) async {
await pumpScreen(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Edit'));
await tester.tap(controlByLabel('Edit'));
await tester.pump();
await tester.enterText(find.byType(TextField), '0');
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await tester.tap(controlByLabel('Save'));
await tester.pump();
expect(find.text('Budget must be a positive number.'), findsOneWidget);
expect(cueLabelled('Budget must be a positive number.'), findsOneWidget);
});
testWidgets('saving a valid value persists it and reverts to read-only', (
@ -123,14 +108,17 @@ void main() {
// frame pumps, mirroring settings_screen_test.dart's device-flow tests.
await tester.runAsync(() async {
await pumpScreen(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Edit'));
await tester.tap(controlByLabel('Edit'));
await tester.pump();
await tester.enterText(find.byType(TextField), '1800');
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await pumpUntil(tester, () => find.text('Saved.').evaluate().isNotEmpty);
await tester.tap(controlByLabel('Save'));
await pumpUntil(
tester,
() => cueLabelled('Saved.').evaluate().isNotEmpty,
);
expect(find.text('Saved.'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Edit'), findsOneWidget);
expect(cueLabelled('Saved.'), findsOneWidget);
expect(controlByLabel('Edit'), findsOneWidget);
expect(AppSettingsService.dailyKcalGoal, 1800);
final field = tester.widget<TextField>(find.byType(TextField));
expect(field.enabled, isFalse);
@ -139,8 +127,8 @@ void main() {
testWidgets('shows streak and year-to-date summary text', (tester) async {
await pumpScreen(tester);
expect(find.textContaining('Logging streak:'), findsOneWidget);
expect(find.textContaining('This year:'), findsOneWidget);
expect(byIconLabel('Logging streak'), findsOneWidget);
expect(byIconLabel('Days logged this year'), findsOneWidget);
});
testWidgets('month navigation steps the displayed month back and forward', (

View File

@ -9,6 +9,7 @@ import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
const _entry = FoodEntry(
id: 'test-uuid',
@ -23,7 +24,6 @@ const _entry = FoodEntry(
);
void main() {
late Directory tempDir;
setUp(() async {
@ -119,7 +119,7 @@ void main() {
'999',
);
await settle(tester);
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
@ -141,7 +141,7 @@ void main() {
await tester.tap(find.text('pizza obok pracy'));
await settle(tester);
expect(find.text('Edit meal'), findsOneWidget);
expect(find.byType(EditEntryScreen), findsOneWidget);
expect(find.text('pizza obok pracy'), findsOneWidget);
// Macros are pre-filled.
expect(find.text('1200'), findsOneWidget);
@ -180,15 +180,16 @@ void main() {
await tester.tap(find.text('pizza obok pracy').last);
await settle(tester);
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await settle(tester);
final saved = (await LogStorageService.instance.readLog())['2026-06-22']!
.first;
final saved =
(await LogStorageService.instance.readLog())['2026-06-22']!.first;
expect(
saved.kcal,
1200,
reason: 'the banked portion must save as itself, not rescaled by '
reason:
'the banked portion must save as itself, not rescaled by '
'its own reference weight.',
);
expect(saved.grams, 400);

View File

@ -9,6 +9,7 @@ import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
const _entry = FoodEntry(
id: 'test-uuid',
@ -23,7 +24,6 @@ const _entry = FoodEntry(
);
void main() {
late Directory tempDir;
setUp(() async {
@ -72,8 +72,8 @@ void main() {
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
expect(find.text('Edit meal'), findsOneWidget);
expect(find.text('Save'), findsOneWidget);
expect(find.byType(EditEntryScreen), findsOneWidget);
expect(byIconLabel('Save'), findsOneWidget);
});
});
testWidgets('shows error when description is cleared', (tester) async {
@ -92,10 +92,10 @@ void main() {
matching: find.byType(TextField),
);
await tester.enterText(descField, '');
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await settle(tester);
expect(find.text('Description cannot be empty.'), findsOneWidget);
expect(cueLabelled('Description cannot be empty.'), findsOneWidget);
});
});
testWidgets('Save persists updated kcal to the log', (tester) async {
@ -110,13 +110,10 @@ void main() {
// Change kcal.
await tester.enterText(
find.ancestor(
of: find.text('1200'),
matching: find.byType(TextField),
),
find.ancestor(of: find.text('1200'), matching: find.byType(TextField)),
'999',
);
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
@ -146,7 +143,7 @@ void main() {
MaterialApp(home: EditEntryScreen(entry: legacy)),
);
await settle(tester);
await tester.tap(find.text('Save'));
await tester.tap(byIconLabel('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();

View File

@ -19,10 +19,13 @@ 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/food_bank/fb_record_tile.dart';
import 'package:diet_guard_app/widgets/today_progress_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
import '../body_screen_support.dart';
import 'fit_support.dart';
@ -74,7 +77,7 @@ void main() {
await _pump(tester, const LogMealScreen());
await tester.enterText(find.byType(TextField).first, 'Oats');
await tester.runAsync(() async {
await tester.tap(find.byTooltip('Log meal'));
await tester.tap(controlByLabel('Log meal'));
await Future<void>.delayed(const Duration(milliseconds: 500));
});
await tester.pump(const Duration(milliseconds: 300));
@ -113,7 +116,7 @@ void main() {
// A tile is hit-testable, and long-press/tap opens its detail sheet.
await tester.tap(first);
await tester.pumpAndSettle();
expect(find.textContaining('P 1 g'), findsOneWidget);
expect(find.byType(RecordTile), findsOneWidget);
await tester.tapAt(const Offset(200, 50)); // scrim closes the sheet
await tester.pumpAndSettle();
// Swiping to the next page reveals foods not on the first page.

View File

@ -1,21 +1,22 @@
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_add_entry_dialog.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
import 'fb_test_support.dart';
void main() {
useTempFoodBankStores();
testWidgets('shows empty-bank message when no entries exist', (
tester,
) async {
testWidgets('shows empty-bank message when no entries exist', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
expect(find.textContaining('Food bank is empty'), findsOneWidget);
expect(byIconLabel('Food bank is empty.'), findsOneWidget);
});
});
@ -50,18 +51,12 @@ void main() {
await tester.tap(find.byType(FloatingActionButton));
await settle(tester);
expect(find.text('Add to food bank'), findsOneWidget);
expect(find.byType(AddEntryDialog), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, 'Name'),
'Test food',
);
await tester.enterText(
find.widgetWithText(TextField, 'Kcal'),
'200',
);
await tester.enterText(fieldByLabel('Name'), 'Test food');
await tester.enterText(fieldByLabel('kcal'), '200');
await tester.tap(find.text('Save to bank'));
await tester.tap(byIconLabel('Save to bank'));
await settle(tester);
// After saving, the screen reloads and shows the new entry.
@ -77,10 +72,10 @@ void main() {
await tester.tap(find.byType(FloatingActionButton));
await settle(tester);
await tester.tap(find.text('Cancel'));
await tester.tap(byIconLabel('Cancel'));
await settle(tester);
expect(find.textContaining('Food bank is empty'), findsOneWidget);
expect(byIconLabel('Food bank is empty.'), findsOneWidget);
});
});
@ -93,11 +88,11 @@ void main() {
await settle(tester);
// Tap save without entering a name.
await tester.tap(find.text('Save to bank'));
await tester.tap(byIconLabel('Save to bank'));
await settle(tester);
// Dialog stays open; no entry saved.
expect(find.text('Add to food bank'), findsOneWidget);
expect(find.byType(AddEntryDialog), findsOneWidget);
});
});
@ -156,17 +151,17 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
expect(find.byType(FbFilterSheet), findsOneWidget);
// Type in the only TextField in the sheet (the name search field).
await tester.enterText(find.byType(TextField).first, 'Oat');
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
// Sheet is closed; only the matching entry is visible.
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FbFilterSheet), findsNothing);
expect(find.text('Oat'), findsOneWidget);
expect(find.text('Egg'), findsNothing);
});
@ -194,10 +189,10 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
await tester.tap(find.text('Clear all'));
await tester.tap(byIconLabel('Clear all'));
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
expect(find.text('Walnut'), findsOneWidget);

View File

@ -2,9 +2,11 @@ import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
import 'fb_test_support.dart';
void main() {
@ -68,21 +70,16 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
expect(find.byType(FbFilterSheet), findsOneWidget);
// With no sliders rendered, 'Sort by' and its dropdown are immediately
// visible — open the sort-field dropdown (shows 'Usage count' by default).
await tester.tap(find.text('Usage count'));
// Usage count is the default sort chip; pick the name chip instead.
await tester.tap(chipByLabel('Name'));
await settle(tester);
// Tap 'Name' in the dropdown overlay.
await tester.tap(find.text('Name').last);
await tester.tap(byIconLabel('Apply'));
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FbFilterSheet), findsNothing);
expect(find.text('ZeroItem'), findsOneWidget);
});
});
@ -140,10 +137,10 @@ void main() {
);
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FbFilterSheet), findsNothing);
});
});
@ -169,13 +166,13 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
expect(find.byType(FbFilterSheet), findsOneWidget);
// Default sort is count-descending; the direction icon is arrow_downward.
await tester.tap(find.byIcon(Icons.arrow_downward));
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
expect(find.text('ZeroItem2'), findsOneWidget);

View File

@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/screens/github_mirror_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
@ -17,6 +18,7 @@ import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import '../fake_secure_storage.dart';
import '../label_finders.dart';
/// Stub launcher that records the URL instead of opening it, so the device
/// dialog's "Open GitHub & copy code" can be exercised without a real
@ -39,8 +41,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform
}
void main() {
late Directory tempDir;
setUp(() async {
@ -48,9 +48,7 @@ void main() {
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
@ -90,13 +88,9 @@ void main() {
}
}
/// Types [clientId] into the client-id field.
Future<void> enterClientId(WidgetTester tester, String clientId) async {
await tester.enterText(
find.widgetWithText(TextField, 'OAuth App client id'),
clientId,
);
await tester.enterText(fieldByLabel('OAuth App client id'), clientId);
}
testWidgets('device flow happy path saves the token and syncs', (
@ -136,7 +130,7 @@ void main() {
await settle(tester);
await enterClientId(tester, 'cid');
await tester.tap(find.text('Connect GitHub'));
await tester.tap(byIconLabel('Connect GitHub'));
await pumpUntil(
tester,
() => find.text('WXYZ-1234').evaluate().isNotEmpty,
@ -147,49 +141,48 @@ void main() {
// post-connect sync runs against the mock.
await pumpUntil(
tester,
() => find.textContaining('Connected and synced').evaluate().isNotEmpty,
() => cueLabelled('Connected and synced.').evaluate().isNotEmpty,
);
expect(find.textContaining('Connected and synced'), findsOneWidget);
expect(cueLabelled('Connected and synced.'), findsOneWidget);
});
});
testWidgets(
'device flow connects but surfaces a post-connect sync failure',
(tester) async {
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({
'device_code': 'dev123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 0,
'expires_in': 900,
}),
200,
);
}
if (req.url.path.contains('login/oauth/access_token')) {
return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
}
return http.Response('boom', 500); // the sync's repo calls fail
});
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
testWidgets('device flow connects but surfaces a post-connect sync failure', (
tester,
) async {
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({
'device_code': 'dev123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 0,
'expires_in': 900,
}),
200,
);
await settle(tester);
await enterClientId(tester, 'cid');
}
if (req.url.path.contains('login/oauth/access_token')) {
return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
}
return http.Response('boom', 500); // the sync's repo calls fail
});
await tester.tap(find.text('Connect GitHub'));
await pumpUntil(
tester,
() => find.textContaining('sync failed').evaluate().isNotEmpty,
);
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
);
await settle(tester);
await enterClientId(tester, 'cid');
expect(find.textContaining('sync failed'), findsOneWidget);
});
},
);
await tester.tap(byIconLabel('Connect GitHub'));
await pumpUntil(
tester,
() => cueOfKind(CueKind.error).evaluate().isNotEmpty,
);
expect(cueOfKind(CueKind.error), findsOneWidget);
});
});
}

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/screens/github_mirror_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
@ -16,6 +17,7 @@ import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import '../fake_secure_storage.dart';
import '../label_finders.dart';
/// Stub launcher that records the URL instead of opening it, so the device
/// dialog's "Open GitHub & copy code" can be exercised without a real
@ -38,7 +40,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform
}
void main() {
late Directory tempDir;
setUp(() async {
@ -46,9 +47,7 @@ void main() {
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
@ -88,13 +87,9 @@ void main() {
}
}
/// Types [clientId] into the client-id field.
Future<void> enterClientId(WidgetTester tester, String clientId) async {
await tester.enterText(
find.widgetWithText(TextField, 'OAuth App client id'),
clientId,
);
await tester.enterText(fieldByLabel('OAuth App client id'), clientId);
}
testWidgets('Connect GitHub without a client id opens setup guidance', (
@ -105,11 +100,11 @@ void main() {
await settle(tester);
await enterClientId(tester, '');
await tester.tap(find.text('Connect GitHub'));
await tester.tap(byIconLabel('Connect GitHub'));
await settle(tester);
expect(find.text('One-time GitHub setup needed'), findsOneWidget);
expect(find.widgetWithText(TextField, 'Client ID'), findsOneWidget);
expect(find.byType(AlertDialog), findsOneWidget);
expect(fieldByLabel('Client ID'), findsOneWidget);
});
});
testWidgets('cancelling the client id setup dialog aborts the connect', (
@ -120,12 +115,12 @@ void main() {
await settle(tester);
await enterClientId(tester, '');
await tester.tap(find.text('Connect GitHub'));
await tester.tap(byIconLabel('Connect GitHub'));
await settle(tester);
await tester.tap(find.text('Cancel'));
await tester.tap(byIconLabel('Cancel'));
await settle(tester);
expect(find.text('One-time GitHub setup needed'), findsNothing);
expect(find.byType(AlertDialog), findsNothing);
});
});
testWidgets(
@ -139,28 +134,16 @@ void main() {
await settle(tester);
await enterClientId(tester, '');
await tester.tap(find.text('Connect GitHub'));
await tester.tap(byIconLabel('Connect GitHub'));
await settle(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Client ID'),
'cid',
);
await tester.tap(find.text('Continue'));
await tester.enterText(fieldByLabel('Client ID'), 'cid');
await tester.tap(byIconLabel('Continue'));
await settle(tester);
expect(cueOfKind(CueKind.error), findsOneWidget);
expect(fieldByLabel('OAuth App client id'), findsOneWidget);
expect(
find.textContaining('Could not start device flow'),
findsOneWidget,
);
expect(
find.widgetWithText(TextField, 'OAuth App client id'),
findsOneWidget,
);
expect(
(tester.widget(
find.widgetWithText(TextField, 'OAuth App client id'),
)
as TextField)
(tester.widget(fieldByLabel('OAuth App client id')) as TextField)
.controller!
.text,
'cid',
@ -177,13 +160,10 @@ void main() {
await settle(tester);
await enterClientId(tester, 'cid');
await tester.tap(find.text('Connect GitHub'));
await tester.tap(byIconLabel('Connect GitHub'));
await settle(tester);
expect(
find.textContaining('Could not start device flow'),
findsOneWidget,
);
expect(cueOfKind(CueKind.error), findsOneWidget);
});
});
}

View File

@ -18,6 +18,7 @@ import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import '../fake_secure_storage.dart';
import '../label_finders.dart';
/// Stub launcher that records the URL instead of opening it, so the device
/// dialog's "Open GitHub & copy code" can be exercised without a real
@ -40,8 +41,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform
}
void main() {
late Directory tempDir;
setUp(() async {
@ -49,9 +48,7 @@ void main() {
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
@ -91,13 +88,9 @@ void main() {
}
}
/// Types [clientId] into the client-id field.
Future<void> enterClientId(WidgetTester tester, String clientId) async {
await tester.enterText(
find.widgetWithText(TextField, 'OAuth App client id'),
clientId,
);
await tester.enterText(fieldByLabel('OAuth App client id'), clientId);
}
testWidgets('device dialog: failed poll shows the error and Open launches', (
@ -144,7 +137,7 @@ void main() {
await settle(tester);
await enterClientId(tester, 'cid');
await tester.tap(find.text('Connect GitHub'));
await tester.tap(byIconLabel('Connect GitHub'));
await pumpUntil(
tester,
() => find.text('WXYZ-1234').evaluate().isNotEmpty,

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/screens/github_mirror_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
@ -17,6 +18,7 @@ import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import '../fake_secure_storage.dart';
import '../label_finders.dart';
/// Stub launcher that records the URL instead of opening it, so the device
/// dialog's "Open GitHub & copy code" can be exercised without a real
@ -39,7 +41,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform
}
void main() {
late Directory tempDir;
setUp(() async {
@ -47,9 +48,7 @@ void main() {
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
@ -89,13 +88,9 @@ void main() {
}
}
/// Types [clientId] into the client-id field.
Future<void> enterClientId(WidgetTester tester, String clientId) async {
await tester.enterText(
find.widgetWithText(TextField, 'OAuth App client id'),
clientId,
);
await tester.enterText(fieldByLabel('OAuth App client id'), clientId);
}
testWidgets('shows the kuhyx/syncs defaults on a fresh install', (
@ -115,31 +110,27 @@ void main() {
await settle(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Personal access token (fallback)'),
fieldByLabel('Personal access token (fallback)'),
'my-pat',
);
await tester.tap(find.widgetWithText(ElevatedButton, 'Save'));
await tester.tap(controlByLabel('Save'));
await settle(tester);
expect(find.text('Saved.'), findsOneWidget);
expect(cueLabelled('Saved.'), findsOneWidget);
});
});
testWidgets('Test connection reports success', (tester) async {
final mock = MockClient(
(_) async => http.Response('{}', 200),
);
final mock = MockClient((_) async => http.Response('{}', 200));
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: GitHubMirrorScreen(httpClient: mock)),
);
await settle(tester);
await tester.tap(
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
);
await tester.tap(controlByLabel('Test GitHub connection'));
await settle(tester);
expect(find.text('GitHub connection OK.'), findsOneWidget);
expect(cueLabelled('GitHub connection OK.'), findsOneWidget);
});
});
testWidgets('Test connection reports failure', (tester) async {
@ -150,17 +141,13 @@ void main() {
);
await settle(tester);
await tester.tap(
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
);
await tester.tap(controlByLabel('Test GitHub connection'));
await settle(tester);
expect(find.text('GitHub connection failed.'), findsOneWidget);
expect(cueLabelled('GitHub connection failed.'), findsOneWidget);
});
});
testWidgets('Sync now runs a sync tick and reports success', (
tester,
) async {
testWidgets('Sync now runs a sync tick and reports success', (tester) async {
final mock = MockClient((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
// A bare `/repos/<owner>/<repo>` GET is crdt_sync's GitHubClient
@ -178,10 +165,10 @@ void main() {
);
await settle(tester);
await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now'));
await tester.tap(controlByLabel('Sync now'));
await settle(tester);
expect(find.text('Synced.'), findsOneWidget);
expect(cueLabelled('Synced.'), findsOneWidget);
});
});
testWidgets('Sync now clears a stored failure so the banner lifts', (
@ -208,10 +195,10 @@ void main() {
);
await settle(tester);
await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now'));
await tester.tap(controlByLabel('Sync now'));
await settle(tester);
expect(find.text('Synced.'), findsOneWidget);
expect(cueLabelled('Synced.'), findsOneWidget);
final status = await SyncHealth.read();
expect(status.failureKind, isNull);
expect(status.isStalled, isFalse);
@ -225,12 +212,10 @@ void main() {
);
await settle(tester);
await tester.tap(
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
);
await tester.tap(controlByLabel('Test GitHub connection'));
await settle(tester);
expect(find.textContaining('GitHub connection failed:'), findsOneWidget);
expect(cueOfKind(CueKind.error), findsOneWidget);
});
});
testWidgets('Sync now reports a GitHub error', (tester) async {
@ -241,10 +226,10 @@ void main() {
);
await settle(tester);
await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now'));
await tester.tap(controlByLabel('Sync now'));
await settle(tester);
expect(find.textContaining('Sync failed:'), findsOneWidget);
expect(cueOfKind(CueKind.error), findsOneWidget);
});
});
}

View File

@ -0,0 +1,55 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
import 'history_test_support.dart';
const _entry = FoodEntry(
id: 'keep-me',
time: '2026-06-22T12:00:00+02:00',
desc: 'lentil soup',
grams: 100,
kcal: 300,
proteinG: 15,
carbsG: 40,
fatG: 5,
source: 'manual',
);
void main() {
useTempHistoryStores();
testWidgets('delete is immediate and the icon-only undo restores it', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
expect(find.text('lentil soup'), findsOneWidget);
await tester.tap(byIconLabel('Delete entry'));
// No confirmation dialog: the row vanishes and a snackbar offers undo.
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('lentil soup'), findsNothing);
expect(bySemLabel('Entry deleted'), findsOneWidget);
await tester.tap(byIconLabel('Undo'));
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('lentil soup'), findsOneWidget);
final stored = await LogStorageService.instance.allEntriesNewestFirst();
expect(stored.single.id, 'keep-me');
expect(stored.single.deleted, isFalse);
});
});
}

View File

@ -37,7 +37,8 @@ void main() {
await settle(tester);
// The raw key is shown as the day header when formatting fails.
expect(find.text('NOT-A-DATE'), findsOneWidget);
// Header falls back to the raw key; the entry row prints its time too.
expect(find.text('NOT-A-DATE'), findsNWidgets(2));
});
},
);

View File

@ -1,9 +1,11 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
import 'history_test_support.dart';
void main() {
@ -34,10 +36,10 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
await tester.tap(find.text('Clear all'));
await tester.tap(byIconLabel('Clear all'));
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
expect(find.text('toast'), findsOneWidget);
@ -72,21 +74,16 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// The sort section is ~8px below the fold even with zero macros — scroll
// it into view before tapping the direction button.
await tester.drag(find.byType(ListView).last, const Offset(0, -120));
await settle(tester);
expect(find.byType(FilterSheet), findsOneWidget);
// Default sort is date-descending; direction icon is arrow_downward.
await tester.tap(find.byIcon(Icons.arrow_downward));
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FilterSheet), findsNothing);
expect(find.textContaining('porridge'), findsOneWidget);
});
});
@ -119,22 +116,14 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
// Scroll just enough to make the sort section visible.
await tester.drag(find.byType(ListView).last, const Offset(0, -120));
// Pick the kcal sort chip (date is selected by default).
await tester.tap(chipByLabel('Kcal'));
await settle(tester);
// Open the sort dropdown (shows 'Date' by default).
await tester.tap(find.text('Date'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
// Select 'Kcal' from the dropdown overlay.
await tester.tap(find.text('Kcal').last);
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FilterSheet), findsNothing);
expect(find.textContaining('chicken'), findsOneWidget);
});
});
@ -175,15 +164,15 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
expect(find.byType(FilterSheet), findsOneWidget);
await tester.tap(find.widgetWithText(FilterChip, 'manual'));
await tester.tap(chipByLabel('manual'));
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FilterSheet), findsNothing);
expect(find.textContaining('manual meal'), findsOneWidget);
expect(find.textContaining('bank meal'), findsNothing);
});

View File

@ -1,9 +1,11 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
import 'history_test_support.dart';
void main() {
@ -41,7 +43,7 @@ void main() {
await settle(tester);
// Day header shows 800 kcal total (300 + 500) vs the 2200 goal.
expect(find.textContaining('800 / 2200 kcal'), findsOneWidget);
expect(find.textContaining('800 / 2200'), findsOneWidget);
// Both entries appear as list tiles.
expect(find.text('breakfast'), findsOneWidget);
expect(find.text('lunch'), findsOneWidget);
@ -146,7 +148,7 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
expect(find.byType(FilterSheet), findsOneWidget);
});
});
@ -191,11 +193,11 @@ void main() {
await tester.enterText(find.byType(TextField).first, 'oat');
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
// Sheet is closed; only the matching entry is visible.
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FilterSheet), findsNothing);
expect(find.text('oat porridge'), findsOneWidget);
expect(find.text('chicken breast'), findsNothing);
});

View File

@ -1,9 +1,11 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
import 'history_test_support.dart';
void main() {
@ -36,16 +38,16 @@ void main() {
// Tap 'manual' to set a source filter, then 'All' to reset it.
// Tapping 'All' when source is not null covers lines 798-800.
await tester.tap(find.widgetWithText(FilterChip, 'manual'));
await tester.tap(chipByLabel('manual'));
await settle(tester);
await tester.tap(find.widgetWithText(FilterChip, 'All'));
await tester.tap(chipByLabel('All'));
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FilterSheet), findsNothing);
expect(find.textContaining('all source entry'), findsOneWidget);
});
});
@ -97,9 +99,7 @@ void main() {
await settle(tester);
// Protein slider.
await tester.ensureVisible(
find.byKey(const Key('protein-range-slider')),
);
await tester.ensureVisible(find.byKey(const Key('protein-range-slider')));
await settle(tester);
await tester.dragFrom(
tester.getRect(find.byKey(const Key('protein-range-slider'))).center,
@ -125,10 +125,10 @@ void main() {
);
await settle(tester);
await tester.tap(find.text('Apply'));
await tester.tap(byIconLabel('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.byType(FilterSheet), findsNothing);
});
});
@ -160,7 +160,7 @@ void main() {
await settle(tester);
// Open date range picker.
await tester.tap(find.widgetWithText(OutlinedButton, 'Any date'));
await tester.tap(controlByLabel('Date range'));
await settle(tester);
// The picker opens on the current month (no `currentDate` override
@ -171,9 +171,9 @@ void main() {
// runs.
final now = DateTime.now();
final expectedStart = DateTime(now.year, now.month);
await tester.tap(find.text('1'));
await tester.tap(find.text('1').last);
await settle(tester);
await tester.tap(find.text('2'));
await tester.tap(find.text('2').last);
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
@ -187,9 +187,9 @@ void main() {
);
// "Clear date range" is now visible — tap it to exercise lines 639-642.
await tester.tap(find.text('Clear date range'));
await tester.tap(byIconLabel('Clear date range'));
await settle(tester);
expect(find.text('Any date'), findsOneWidget);
expect(byIconLabel('Clear date range'), findsNothing);
});
},
);

View File

@ -6,6 +6,7 @@ import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
import 'history_test_support.dart';
void main() {
@ -61,8 +62,8 @@ void main() {
// Same 2100 kcal on both days, judged against different budgets --
// lowering the budget must not repaint the earlier day.
expect(find.text('2100 / 2200 kcal'), findsOneWidget);
expect(find.text('2100 / 2000 kcal'), findsOneWidget);
expect(find.text('2100 / 2200'), findsOneWidget);
expect(find.text('2100 / 2000'), findsOneWidget);
});
});
@ -71,7 +72,7 @@ void main() {
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
expect(find.text('Nothing logged yet.'), findsOneWidget);
expect(byIconLabel('Nothing logged yet.'), findsOneWidget);
});
});

View File

@ -22,6 +22,7 @@ import 'package:diet_guard_app/services/kuchnia_fill_flow.dart';
import 'package:diet_guard_app/services/kuchnia_import.dart';
import 'package:diet_guard_app/services/kuchnia_queue.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/ui/cues.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -50,6 +51,7 @@ class _HostState extends State<_Host>
final MacroControllers macros = MacroControllers();
int publishes = 0;
String status = '';
CueKind statusKind = CueKind.info;
@override
int? selectedSlot;
@ -73,8 +75,14 @@ class _HostState extends State<_Host>
);
}
int? statusCount;
@override
void showFillStatus(String message) => setState(() => status = message);
void showFillStatus(FillCue cue) => setState(() {
status = cue.label;
statusKind = cue.kind;
statusCount = cue.count;
});
/// Counted instead of run: the real one starts a network sync.
@override
@ -117,6 +125,8 @@ void main() {
await t.runAsync(host.onFillAll);
expect(host.status, startsWith('Will log 4'));
// Painted: a glyph + the number of dishes the confirm would log.
expect(host.statusCount, 4);
expect(await t.runAsync(storage.todayEntries), isEmpty);
expect(host.publishes, 0);
expect(KuchniaQueueService.remaining, 4);
@ -134,6 +144,8 @@ void main() {
});
expect(host.status, 'Logged 4 catering dishes.');
expect(host.statusKind, CueKind.ok);
expect(host.statusCount, 4);
expect(await t.runAsync(() => loggedToday(storage)), hasLength(4));
expect(host.publishes, 1, reason: 'one publish per batch, not per dish');
expect(KuchniaQueueService.remaining, 0);
@ -155,6 +167,7 @@ void main() {
// D1's slot was taken, so only D2..D4 landed and D1 is still on offer.
expect(host.status, 'Logged 3 catering dishes.');
expect(host.statusCount, 3);
expect(KuchniaQueueService.remaining, 1);
expect(host.desc.text, 'D1');
});
@ -186,6 +199,7 @@ void main() {
await t.runAsync(host.onFillAll);
expect(host.status, startsWith('Fill all failed'));
expect(host.statusKind, CueKind.error);
expect(host.publishes, 1, reason: 'a partial batch must still sync');
expect(host.fillFlow.busy, isFalse);
});

View File

@ -13,6 +13,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
void main() {
late Directory dir;
final haptics = <String>[];
@ -60,7 +62,7 @@ void main() {
await settle(tester);
await tester.enterText(find.byType(TextField).at(0), 'toast');
await settle(tester);
await tester.tap(find.byTooltip('Log meal'));
await tester.tap(controlByLabel('Log meal'));
// One frame, no I/O wait: the haptic must not wait for the write.
await tester.pump();
expect(haptics, ['HapticFeedbackType.lightImpact']);
@ -80,23 +82,24 @@ void main() {
await settle(tester);
await tester.enterText(find.byType(TextField).at(0), 'toast');
await settle(tester);
await tester.tap(find.byTooltip('Log meal'));
await tester.tap(controlByLabel('Log meal'));
await settle(tester);
expect(haptics, ['HapticFeedbackType.lightImpact']);
expect(cues, 0);
});
});
testWidgets('a rejected log (empty description) does not buzz', (
testWidgets('a rejected log (empty description) gives only the error tick', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.byTooltip('Log meal'));
await tester.tap(controlByLabel('Log meal'));
await settle(tester);
expect(find.text('Type what you ate first.'), findsOneWidget);
expect(haptics, isEmpty);
expect(cueLabelled('Type what you ate first.'), findsOneWidget);
// Only the error cue's own heavy tick: no success buzz.
expect(haptics, ['HapticFeedbackType.heavyImpact']);
expect(cues, 0);
});
});

View File

@ -12,6 +12,8 @@ import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
/// Covers logging a meal against a future date/slot through [LogMealScreen]'s
/// "When" row -- its own file, split out the same way
/// `log_meal_screen_nav_test.dart` and `log_meal_screen_sync_test.dart` are.
@ -36,7 +38,7 @@ void main() {
await tempDir.delete(recursive: true);
});
final logMealButton = find.byTooltip('Log meal');
final logMealButton = controlByLabel('Log meal');
// See log_meal_screen_test.dart's `settle` for why: fire-and-forget I/O
// that pumpAndSettle() alone does not wait for.
@ -58,7 +60,7 @@ void main() {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.text('Log for later'));
await tester.tap(controlByLabel('Log for later'));
await settle(tester);
// The date picker's initialDate is already tomorrow, so confirming
// immediately picks it without touching a specific calendar cell.
@ -89,8 +91,8 @@ void main() {
expect(future.time.startsWith(tomorrowKey), isTrue);
// Back to logging for today.
expect(find.text('Today'), findsOneWidget);
expect(find.text('Log for later'), findsOneWidget);
expect(find.text(tomorrowKey), findsNothing);
expect(controlByLabel('Log for later'), findsOneWidget);
});
},
);
@ -102,7 +104,7 @@ void main() {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.text('Log for later'));
await tester.tap(controlByLabel('Log for later'));
await settle(tester);
await tester.tap(find.text('OK'));
await settle(tester);
@ -114,7 +116,7 @@ void main() {
await tester.tap(logMealButton);
await settle(tester);
expect(find.text('Pick a slot for that date first.'), findsOneWidget);
expect(cueLabelled('Pick a slot for that date first.'), findsOneWidget);
final log = await LogStorageService.instance.readLog();
expect(log.values.expand((e) => e), isEmpty);
});
@ -127,17 +129,17 @@ void main() {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.text('Log for later'));
await tester.tap(controlByLabel('Log for later'));
await settle(tester);
await tester.tap(find.text('OK'));
await settle(tester);
expect(find.text('Today'), findsNothing);
expect(find.text(localDateKey(tomorrow())), findsOneWidget);
await tester.tap(find.byTooltip('Back to today'));
await tester.tap(controlByLabel('Back to today'));
await settle(tester);
expect(find.text('Today'), findsOneWidget);
expect(find.text('Log for later'), findsOneWidget);
expect(find.text(localDateKey(tomorrow())), findsNothing);
expect(controlByLabel('Log for later'), findsOneWidget);
});
});
}

View File

@ -15,6 +15,8 @@ import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../label_finders.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
@ -65,7 +67,7 @@ void main() {
await tempDir.delete(recursive: true);
});
final logMealButton = find.byTooltip('Log meal');
final logMealButton = controlByLabel('Log meal');
// The screen's button handlers and description-field listener trigger
// real `dart:io` file I/O as fire-and-forget Futures that Flutter's frame
@ -107,7 +109,7 @@ void main() {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.byTooltip('Body'));
await tester.tap(controlByLabel('Body'));
await settle(tester);
expect(find.byType(BodyScreen), findsOneWidget);

Some files were not shown because too many files have changed in this diff Show More