mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:27:39 +01:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 649a324f8a | |||
| 4eca59bac9 | |||
| f4b4a034b7 | |||
| 57d304600e | |||
| ded7486244 | |||
| 8068205d0c | |||
| 91a9ee5295 | |||
| 0982565fdc | |||
| 957ddc7ddc | |||
| e7ef648757 | |||
| dc2799e36a | |||
| 2dc502bbb6 | |||
| c9c30b1ee5 | |||
| ff12f4e171 | |||
| 43e4b4070d | |||
| 45627f7075 | |||
| 60ce1a6d71 | |||
| 445712dea3 | |||
| 752c3704b2 | |||
| f25d22da20 | |||
| 737d09d37c | |||
| 83d05c637a | |||
| 07e5d2149c | |||
| 832f9ab7da | |||
| 3b200b60e7 |
@ -1,4 +1,7 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+UseSerialGC -XX:TieredStopAtLevel=1
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
# This newDsl flag was added by the Flutter template
|
# This newDsl flag was added by the Flutter template
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
|
|||||||
35
app/lib/desktop/error_page.dart
Normal file
35
app/lib/desktop/error_page.dart
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ library;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/desktop/error_page.dart' show isNavigation;
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
/// GitHub's REST API base, which [GitHubProxy] fronts.
|
/// GitHub's REST API base, which [GitHubProxy] fronts.
|
||||||
@ -177,8 +178,13 @@ class GitHubProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _passThrough(HttpRequest request, http.Response response) {
|
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
|
request.response
|
||||||
..statusCode = response.statusCode
|
|
||||||
..headers.contentType = ContentType.json
|
..headers.contentType = ContentType.json
|
||||||
..add(response.bodyBytes);
|
..add(response.bodyBytes);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import 'dart:io';
|
|||||||
import 'package:crdt_sync/crdt_sync.dart';
|
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/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/desktop/github_proxy.dart';
|
||||||
import 'package:diet_guard_app/services/desktop_wrapper.dart';
|
import 'package:diet_guard_app/services/desktop_wrapper.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
@ -91,6 +92,7 @@ class WrapperServer {
|
|||||||
} on Exception {
|
} on Exception {
|
||||||
request.response.statusCode = HttpStatus.internalServerError;
|
request.response.statusCode = HttpStatus.internalServerError;
|
||||||
}
|
}
|
||||||
|
paintErrorIcon(request);
|
||||||
await request.response.close();
|
await request.response.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/body_service.dart';
|
||||||
import 'package:diet_guard_app/services/health_steps.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/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/ui/theme.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/activity_card.dart';
|
import 'package:diet_guard_app/widgets/body/activity_card.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/body_section.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 (exercise, _, _) = bodyExercise(body, bio, today);
|
||||||
final tiles = _tiles(body, bio, exercise);
|
final tiles = _tiles(body, bio, exercise);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Body')),
|
appBar: AppBar(),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
|
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
|
||||||
child: Column(
|
child: BodyTileGrid(tiles: tiles),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
BodyTileGrid(tiles: tiles),
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
BodyNote(
|
|
||||||
'Age ${bodyAgeText(body, today)} · synced with the PC.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -159,8 +152,12 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
) {
|
) {
|
||||||
final weight = body.latestWeight?.$2;
|
final weight = body.latestWeight?.$2;
|
||||||
final bodyFat = body.latestBodyFat?.$2;
|
final bodyFat = body.latestBodyFat?.$2;
|
||||||
|
final age = bodyAgeText(body, DateTime.now());
|
||||||
final profile = BodyTileSpec(
|
final profile = BodyTileSpec(
|
||||||
title: 'Profile',
|
semanticLabel: 'Profile',
|
||||||
|
icon: Icons.person,
|
||||||
|
value: age,
|
||||||
|
valueIcon: age == null ? null : Icons.cake,
|
||||||
builder: (_) => ProfileCard(onSaved: _onChanged),
|
builder: (_) => ProfileCard(onSaved: _onChanged),
|
||||||
);
|
);
|
||||||
final goal = bio == null
|
final goal = bio == null
|
||||||
@ -174,45 +171,65 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
return [
|
return [
|
||||||
if (_profileAtTop) profile,
|
if (_profileAtTop) profile,
|
||||||
BodyTileSpec(
|
BodyTileSpec(
|
||||||
title: 'Daily calorie target',
|
semanticLabel: 'Daily calorie target',
|
||||||
value: goal == null ? null : '$goal kcal',
|
icon: Icons.flag,
|
||||||
|
value: goal == null ? null : '$goal',
|
||||||
|
valueIcon: goal == null ? null : MacroKind.kcal.icon,
|
||||||
builder: (_) => bio == null
|
builder: (_) => bio == null
|
||||||
? BodySection(
|
? BodySection(
|
||||||
title: 'Daily calorie target',
|
semanticLabel: 'Daily calorie target',
|
||||||
children: [BodyNote(bodyMissingNote(body))],
|
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),
|
: GoalCard(bio: bio, exercise: exercise, bodyFatPct: bodyFat),
|
||||||
),
|
),
|
||||||
BodyTileSpec(
|
BodyTileSpec(
|
||||||
title: 'Weight',
|
semanticLabel: 'Weight',
|
||||||
|
icon: Icons.monitor_weight,
|
||||||
value: weight == null ? null : formatKg(weight),
|
value: weight == null ? null : formatKg(weight),
|
||||||
builder: (_) => WeightCard(onChanged: _onChanged),
|
builder: (_) => WeightCard(onChanged: _onChanged),
|
||||||
),
|
),
|
||||||
if (bio != null) ...[
|
if (bio != null) ...[
|
||||||
BodyTileSpec(
|
BodyTileSpec(
|
||||||
title: 'BMI',
|
semanticLabel: 'BMI',
|
||||||
|
icon: Icons.accessibility_new,
|
||||||
value: oneDecimal(bmiReport(bio).bmi),
|
value: oneDecimal(bmiReport(bio).bmi),
|
||||||
builder: (_) => BmiCard(bio: bio),
|
builder: (_) => BmiCard(bio: bio),
|
||||||
),
|
),
|
||||||
BodyTileSpec(
|
BodyTileSpec(
|
||||||
title: 'Ideal weight',
|
semanticLabel: 'Ideal weight',
|
||||||
|
icon: Icons.balance,
|
||||||
builder: (_) => IdealWeightCard(bio: bio),
|
builder: (_) => IdealWeightCard(bio: bio),
|
||||||
),
|
),
|
||||||
BodyTileSpec(
|
BodyTileSpec(
|
||||||
title: 'Resting metabolism (BMR)',
|
semanticLabel: 'Resting metabolism (BMR)',
|
||||||
value: '${whole(tableBmr(bio, bodyFat).value!)} kcal',
|
icon: Icons.bedtime,
|
||||||
|
value: whole(tableBmr(bio, bodyFat).value!),
|
||||||
|
valueIcon: MacroKind.kcal.icon,
|
||||||
builder: (_) => BmrCard(bio: bio, bodyFatPct: bodyFat),
|
builder: (_) => BmrCard(bio: bio, bodyFatPct: bodyFat),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
BodyTileSpec(
|
BodyTileSpec(
|
||||||
title: 'Activity',
|
semanticLabel: 'Activity',
|
||||||
|
icon: Icons.directions_run,
|
||||||
builder: (_) => ValueListenableBuilder<bool>(
|
builder: (_) => ValueListenableBuilder<bool>(
|
||||||
valueListenable: _canConnectSteps,
|
valueListenable: _canConnectSteps,
|
||||||
builder: (_, canConnect, _) => _activityCard(canConnect: canConnect),
|
builder: (_, canConnect, _) => _activityCard(canConnect: canConnect),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BodyTileSpec(
|
BodyTileSpec(
|
||||||
title: 'Week planner (info only)',
|
semanticLabel: 'Week planner',
|
||||||
|
icon: Icons.calendar_view_week,
|
||||||
builder: (_) => WeekPlanCard(
|
builder: (_) => WeekPlanCard(
|
||||||
defaultTarget: AppSettingsService.dailyKcalGoal,
|
defaultTarget: AppSettingsService.dailyKcalGoal,
|
||||||
logged: _logged,
|
logged: _logged,
|
||||||
@ -220,7 +237,8 @@ class _BodyScreenState extends State<BodyScreen> {
|
|||||||
),
|
),
|
||||||
if (!_profileAtTop) profile,
|
if (!_profileAtTop) profile,
|
||||||
BodyTileSpec(
|
BodyTileSpec(
|
||||||
title: 'Body fat',
|
semanticLabel: 'Body fat',
|
||||||
|
icon: Icons.percent,
|
||||||
value: bodyFat == null ? null : '${oneDecimal(bodyFat)} %',
|
value: bodyFat == null ? null : '${oneDecimal(bodyFat)} %',
|
||||||
builder: (_) => BodyFatCard(onChanged: _onChanged),
|
builder: (_) => BodyFatCard(onChanged: _onChanged),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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_service.dart';
|
||||||
import 'package:diet_guard_app/services/body_steps.dart';
|
import 'package:diet_guard_app/services/body_steps.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_service.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
|
/// Builds the person from the stored profile and newest weight, or null
|
||||||
/// when anything the formulas need is missing.
|
/// 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),
|
.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) {
|
String bodyMissingNote(BodyService body) {
|
||||||
if (body.latestWeight == null && body.birth == null) {
|
if (body.latestWeight == null && body.birth == null) {
|
||||||
return 'Set your profile and enter your weight to see BMI, ideal '
|
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.';
|
return 'Set your profile (birth date, height, sex) to see the numbers.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The age line value.
|
/// The age in whole years, or null while no birth date is stored.
|
||||||
String bodyAgeText(BodyService body, DateTime today) {
|
String? bodyAgeText(BodyService body, DateTime today) {
|
||||||
final birth = DateTime.tryParse(body.birth ?? '');
|
final birth = DateTime.tryParse(body.birth ?? '');
|
||||||
return birth == null ? 'unknown' : '${ageOn(birth, today)}';
|
return birth == null ? null : '${ageOn(birth, today)}';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/budget_history_service.dart';
|
||||||
import 'package:diet_guard_app/services/day_status_service.dart';
|
import 'package:diet_guard_app/services/day_status_service.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
import 'package:diet_guard_app/services/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/average_summary_row.dart';
|
||||||
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
|
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
|
||||||
import 'package:diet_guard_app/widgets/streak_summary_row.dart';
|
import 'package:diet_guard_app/widgets/streak_summary_row.dart';
|
||||||
@ -153,9 +155,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
if (_loading) {
|
if (_loading) {
|
||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
}
|
}
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Calendar')),
|
appBar: AppBar(),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -168,11 +169,10 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
if (_budgetStatus != null)
|
if (_budgetStatus != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: StatusCue(
|
||||||
_budgetStatus!,
|
key: ValueKey(_budgetStatus),
|
||||||
style: TextStyle(
|
kind: _budgetError ? CueKind.error : CueKind.ok,
|
||||||
color: _budgetError ? theme.colorScheme.error : null,
|
semanticLabel: _budgetStatus!,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@ -215,7 +215,11 @@ class _BudgetRow extends StatelessWidget {
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text('Daily budget (kcal):'),
|
Icon(
|
||||||
|
MacroKind.kcal.icon,
|
||||||
|
color: MacroKind.kcal.color,
|
||||||
|
semanticLabel: 'Daily budget (kcal)',
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 90,
|
width: 90,
|
||||||
@ -230,7 +234,10 @@ class _BudgetRow extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: onEditOrSave,
|
onPressed: onEditOrSave,
|
||||||
child: Text(editing ? 'Save' : 'Edit'),
|
child: Icon(
|
||||||
|
editing ? Icons.check : Icons.edit,
|
||||||
|
semanticLabel: editing ? 'Save' : 'Edit',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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/models/nutrition.dart';
|
||||||
import 'package:diet_guard_app/services/foodbank_service.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/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/autocomplete_suggestion_list.dart';
|
||||||
import 'package:diet_guard_app/widgets/macro_input_row.dart';
|
import 'package:diet_guard_app/widgets/macro_input_row.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -35,6 +36,7 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
|
|||||||
List<FoodSuggestion> _suggestions = const [];
|
List<FoodSuggestion> _suggestions = const [];
|
||||||
String _source = 'manual';
|
String _source = 'manual';
|
||||||
String? _status;
|
String? _status;
|
||||||
|
int _rejections = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -80,9 +82,7 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onDescChanged() async {
|
Future<void> _onDescChanged() async {
|
||||||
final matches = await FoodBankService.instance.search(
|
final matches = await FoodBankService.instance.search(_descController.text);
|
||||||
_descController.text,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _suggestions = matches);
|
setState(() => _suggestions = matches);
|
||||||
}
|
}
|
||||||
@ -104,7 +104,10 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
|
|||||||
Future<void> _onSave() async {
|
Future<void> _onSave() async {
|
||||||
final desc = _descController.text.trim();
|
final desc = _descController.text.trim();
|
||||||
if (desc.isEmpty) {
|
if (desc.isEmpty) {
|
||||||
setState(() => _status = 'Description cannot be empty.');
|
setState(() {
|
||||||
|
_status = 'Description cannot be empty.';
|
||||||
|
_rejections++;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final nutrition = nutritionForPortion(
|
final nutrition = nutritionForPortion(
|
||||||
@ -142,17 +145,16 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Edit meal')),
|
appBar: AppBar(),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
IconField(
|
||||||
|
icon: Icons.restaurant,
|
||||||
|
semanticLabel: 'What did you eat?',
|
||||||
controller: _descController,
|
controller: _descController,
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'What did you eat?',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 240),
|
constraints: const BoxConstraints(maxHeight: 240),
|
||||||
@ -168,12 +170,20 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _onSave,
|
onPressed: _onSave,
|
||||||
child: const Text('Save'),
|
child: const Icon(Icons.check, semanticLabel: 'Save'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_status != null) ...[
|
if (_status != null) ...[
|
||||||
const SizedBox(height: 12),
|
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!,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -153,15 +153,16 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
|
|||||||
final all = _allEntries;
|
final all = _allEntries;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Food Bank'),
|
|
||||||
actions: [
|
actions: [
|
||||||
if (all != null)
|
if (all != null)
|
||||||
Stack(
|
Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.filter_list),
|
icon: const Icon(
|
||||||
tooltip: 'Filter & sort',
|
Icons.filter_list,
|
||||||
|
semanticLabel: 'Filter & sort',
|
||||||
|
),
|
||||||
onPressed: _openFilterSheet,
|
onPressed: _openFilterSheet,
|
||||||
),
|
),
|
||||||
if (_filter.isActive)
|
if (_filter.isActive)
|
||||||
@ -185,19 +186,19 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
|
|||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _displayed.isEmpty
|
: _displayed.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Icon(
|
||||||
all.isEmpty
|
all.isEmpty ? Icons.restaurant_menu : Icons.filter_list_off,
|
||||||
? 'Food bank is empty.\n'
|
size: 64,
|
||||||
'Log meals to populate it, or add entries manually.'
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
semanticLabel: all.isEmpty
|
||||||
|
? 'Food bank is empty.'
|
||||||
: 'No entries match the current filter.',
|
: 'No entries match the current filter.',
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: FbTileGrid(records: _displayed, onOpen: _openDetail),
|
: FbTileGrid(records: _displayed, onOpen: _openDetail),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: _openAddDialog,
|
onPressed: _openAddDialog,
|
||||||
tooltip: 'Add manual entry',
|
child: const Icon(Icons.add, semanticLabel: 'Add manual entry'),
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,54 +28,27 @@ class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return AlertDialog(
|
||||||
title: const Text('One-time GitHub setup needed'),
|
content: IconField(
|
||||||
content: SingleChildScrollView(
|
icon: Icons.apps,
|
||||||
child: Column(
|
semanticLabel: 'Client ID',
|
||||||
mainAxisSize: MainAxisSize.min,
|
controller: _controller,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
autofocus: true,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('Cancel'),
|
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
IconButton.filled(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final id = _controller.text.trim();
|
final id = _controller.text.trim();
|
||||||
if (id.isNotEmpty) Navigator.of(context).pop(id);
|
if (id.isNotEmpty) Navigator.of(context).pop(id);
|
||||||
},
|
},
|
||||||
child: const Text('Continue'),
|
icon: const Icon(Icons.arrow_forward, semanticLabel: 'Continue'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
|
|||||||
TextEditingController get _clientIdController;
|
TextEditingController get _clientIdController;
|
||||||
|
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _status;
|
Widget? _status;
|
||||||
String _storedToken = '';
|
String _storedToken = '';
|
||||||
SyncSettings _currentSettings() {
|
SyncSettings _currentSettings() {
|
||||||
final typed = _tokenController.text.trim();
|
final typed = _tokenController.text.trim();
|
||||||
@ -37,9 +37,18 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
|
|||||||
clientId: _clientIdController.text.trim(),
|
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;
|
if (!mounted) return;
|
||||||
setState(() => _status = message);
|
setState(
|
||||||
|
() => _status = StatusCue(
|
||||||
|
key: UniqueKey(),
|
||||||
|
kind: kind,
|
||||||
|
semanticLabel: semanticLabel,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connectGitHub() async {
|
Future<void> _connectGitHub() async {
|
||||||
@ -69,12 +78,12 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
|
|||||||
_storedToken = token;
|
_storedToken = token;
|
||||||
if (SyncSettings.exposesTokenValue) _tokenController.text = token;
|
if (SyncSettings.exposesTokenValue) _tokenController.text = token;
|
||||||
});
|
});
|
||||||
_showMessage('Connected — syncing…');
|
_showCue(CueKind.info, 'Connected — syncing…');
|
||||||
await _currentSettings().save();
|
await _currentSettings().save();
|
||||||
await _syncAfterConnect();
|
await _syncAfterConnect();
|
||||||
}
|
}
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
_showMessage('Could not start device flow: $e');
|
_showCue(CueKind.error, 'Could not start device flow: $e');
|
||||||
} finally {
|
} finally {
|
||||||
auth.close();
|
auth.close();
|
||||||
}
|
}
|
||||||
@ -88,10 +97,10 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
|
|||||||
try {
|
try {
|
||||||
await runSync(await syncBackend(client));
|
await runSync(await syncBackend(client));
|
||||||
await SyncHealth.recordSuccess();
|
await SyncHealth.recordSuccess();
|
||||||
_showMessage('Connected and synced.');
|
_showCue(CueKind.ok, 'Connected and synced.');
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
await SyncHealth.recordFailure();
|
await SyncHealth.recordFailure();
|
||||||
_showMessage('Connected, but sync failed: $e');
|
_showCue(CueKind.error, 'Connected, but sync failed: $e');
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
@ -102,7 +111,7 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
|
|||||||
await _currentSettings().save();
|
await _currentSettings().save();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _busy = false);
|
setState(() => _busy = false);
|
||||||
_showMessage('Saved.');
|
_showCue(CueKind.ok, 'Saved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _testConnection() async {
|
Future<void> _testConnection() async {
|
||||||
@ -111,11 +120,13 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
|
|||||||
final client = createGitHubClient(settings, httpClient: widget.httpClient);
|
final client = createGitHubClient(settings, httpClient: widget.httpClient);
|
||||||
try {
|
try {
|
||||||
final ok = await client.canAccessRepo();
|
final ok = await client.canAccessRepo();
|
||||||
_showMessage(
|
if (ok) {
|
||||||
ok ? 'GitHub connection OK.' : 'GitHub connection failed.',
|
_showCue(CueKind.ok, 'GitHub connection OK.');
|
||||||
);
|
} else {
|
||||||
|
_showCue(CueKind.error, 'GitHub connection failed.');
|
||||||
|
}
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
_showMessage('GitHub connection failed: $e');
|
_showCue(CueKind.error, 'GitHub connection failed: $e');
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
if (mounted) setState(() => _busy = false);
|
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
|
// *because* the banner told them syncing had stopped, so a successful
|
||||||
// run here must dismiss the warning it caused.
|
// run here must dismiss the warning it caused.
|
||||||
await SyncHealth.recordSuccess();
|
await SyncHealth.recordSuccess();
|
||||||
_showMessage('Synced.');
|
_showCue(CueKind.ok, 'Synced.');
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
await SyncHealth.recordFailure();
|
await SyncHealth.recordFailure();
|
||||||
_showMessage('Sync failed: $e');
|
_showCue(CueKind.error, 'Sync failed: $e');
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _busy = false);
|
||||||
|
|||||||
@ -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_health.dart';
|
||||||
import 'package:diet_guard_app/services/sync_service.dart';
|
import 'package:diet_guard_app/services/sync_service.dart';
|
||||||
import 'package:diet_guard_app/services/sync_settings.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:diet_guard_app/ui/theme.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:github_device_auth/github_device_auth.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
|
/// The token as loaded, so a platform that cannot display it (web) still
|
||||||
/// round-trips it instead of blanking it on the next save.
|
/// round-trips it instead of blanking it on the next save.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Runs the OAuth device flow and, on success, fills in the token field.
|
/// Runs the OAuth device flow and, on success, fills in the token field.
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
}
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
|
// No title and no explanatory paragraph: the code glyph that opened this
|
||||||
// Centred and capped at the prose width -- see settings_screen.dart's
|
// screen names it, and the connect button is self-evident.
|
||||||
// note on why: on the desktop surface an uncapped ListView spanned the
|
appBar: AppBar(),
|
||||||
// full window, so explanatory paragraphs ran far past the readable
|
// Centred and capped at the prose width so fields do not span the
|
||||||
// line-length limit.
|
// whole window on the desktop surface.
|
||||||
body: Center(
|
body: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
|
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
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(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: FilledButton.icon(
|
child: IconButton.filled(
|
||||||
onPressed: _connectGitHub,
|
onPressed: _connectGitHub,
|
||||||
icon: const Icon(Icons.login),
|
icon: const Icon(
|
||||||
label: const Text('Connect GitHub'),
|
Icons.login,
|
||||||
|
semanticLabel: 'Connect GitHub',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
IconField(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
semanticLabel: 'GitHub owner',
|
||||||
controller: _ownerController,
|
controller: _ownerController,
|
||||||
decoration: const InputDecoration(labelText: 'GitHub owner'),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
IconField(
|
||||||
|
icon: Icons.folder_outlined,
|
||||||
|
semanticLabel: 'Repo',
|
||||||
controller: _repoController,
|
controller: _repoController,
|
||||||
decoration: const InputDecoration(labelText: 'Repo'),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
IconField(
|
||||||
|
icon: Icons.apps,
|
||||||
|
semanticLabel: 'OAuth App client id',
|
||||||
controller: _clientIdController,
|
controller: _clientIdController,
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'OAuth App client id',
|
|
||||||
helperText: 'Needed for the Connect GitHub button',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
IconField(
|
||||||
|
icon: Icons.key,
|
||||||
|
semanticLabel: 'Personal access token (fallback)',
|
||||||
controller: _tokenController,
|
controller: _tokenController,
|
||||||
obscureText: true,
|
obscure: 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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
IconButton.filled(
|
||||||
onPressed: _busy ? null : _save,
|
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,
|
onPressed: _busy ? null : _testConnection,
|
||||||
// Named for what it actually does: _testConnection builds
|
icon: const Icon(
|
||||||
// a GitHub client and calls canAccessRepo(). It says
|
Icons.network_check,
|
||||||
// nothing about Firebase, and the bare label implied
|
semanticLabel: 'Test GitHub connection',
|
||||||
// otherwise.
|
),
|
||||||
child: const Text('Test GitHub connection'),
|
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
IconButton.filled(
|
||||||
onPressed: _busy ? null : _syncNow,
|
onPressed: _busy ? null : _syncNow,
|
||||||
child: const Text('Sync now'),
|
icon: const Icon(Icons.sync, semanticLabel: 'Sync now'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_status != null) ...[
|
if (_status != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
|
Align(alignment: Alignment.centerLeft, child: _status),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -159,15 +159,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
final allEntries = _allEntries;
|
final allEntries = _allEntries;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('History'),
|
|
||||||
actions: [
|
actions: [
|
||||||
if (allEntries != null)
|
if (allEntries != null)
|
||||||
Stack(
|
Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.filter_list),
|
icon: const Icon(
|
||||||
tooltip: 'Filter & sort',
|
Icons.filter_list,
|
||||||
|
semanticLabel: 'Filter & sort',
|
||||||
|
),
|
||||||
onPressed: _openFilterSheet,
|
onPressed: _openFilterSheet,
|
||||||
),
|
),
|
||||||
if (_filter.isActive)
|
if (_filter.isActive)
|
||||||
@ -191,8 +192,11 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _displayed.isEmpty
|
: _displayed.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Icon(
|
||||||
allEntries.isEmpty
|
allEntries.isEmpty ? Icons.restaurant : Icons.filter_list_off,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
semanticLabel: allEntries.isEmpty
|
||||||
? 'Nothing logged yet.'
|
? 'Nothing logged yet.'
|
||||||
: 'No entries match the current filter.',
|
: 'No entries match the current filter.',
|
||||||
),
|
),
|
||||||
|
|||||||
@ -45,28 +45,23 @@ List<Widget> logMealAppBarActions({
|
|||||||
required VoidCallback onBody,
|
required VoidCallback onBody,
|
||||||
}) => [
|
}) => [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.monitor_weight),
|
icon: const Icon(Icons.monitor_weight, semanticLabel: 'Body'),
|
||||||
tooltip: 'Body',
|
|
||||||
onPressed: onBody,
|
onPressed: onBody,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.restaurant_menu),
|
icon: const Icon(Icons.restaurant_menu, semanticLabel: 'Food bank'),
|
||||||
tooltip: 'Food bank',
|
|
||||||
onPressed: onFoodBank,
|
onPressed: onFoodBank,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.history),
|
icon: const Icon(Icons.history, semanticLabel: 'History'),
|
||||||
tooltip: 'History',
|
|
||||||
onPressed: onHistory,
|
onPressed: onHistory,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.calendar_month),
|
icon: const Icon(Icons.calendar_month, semanticLabel: 'Calendar'),
|
||||||
tooltip: 'Calendar',
|
|
||||||
onPressed: onCalendar,
|
onPressed: onCalendar,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings, semanticLabel: 'Sync settings'),
|
||||||
tooltip: 'Sync settings',
|
|
||||||
onPressed: onSettings,
|
onPressed: onSettings,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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_fill_flow.dart';
|
||||||
import 'package:diet_guard_app/services/kuchnia_queue.dart';
|
import 'package:diet_guard_app/services/kuchnia_queue.dart';
|
||||||
import 'package:diet_guard_app/services/kuchnia_spread.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/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.
|
/// Drives the screen's [FillAllFlow] and tidies the screen after a fill.
|
||||||
mixin LogMealFillMixin<T extends StatefulWidget>
|
mixin LogMealFillMixin<T extends StatefulWidget>
|
||||||
on LogMealSyncMixin<T>, LogMealKuchniaMixin<T>, LogMealFutureMixin<T> {
|
on LogMealSyncMixin<T>, LogMealKuchniaMixin<T>, LogMealFutureMixin<T> {
|
||||||
@ -21,8 +36,9 @@ mixin LogMealFillMixin<T extends StatefulWidget>
|
|||||||
/// armed proposal along with it.
|
/// armed proposal along with it.
|
||||||
FillAllFlow fillFlow = FillAllFlow();
|
FillAllFlow fillFlow = FillAllFlow();
|
||||||
|
|
||||||
/// Shows [message] in the form's status line. Supplied by the screen.
|
/// Shows [cue] as a status cue (its label is only spoken). Supplied by the
|
||||||
void showFillStatus(String message);
|
/// screen.
|
||||||
|
void showFillStatus(FillCue cue);
|
||||||
|
|
||||||
/// A proposal belongs to the date it was fetched for; picking another (or
|
/// A proposal belongs to the date it was fetched for; picking another (or
|
||||||
/// going back to today) must not leave "Confirm (N)" offering it.
|
/// 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.
|
// for the next tick, and the user is told the batch did not finish.
|
||||||
await publishAfterLog();
|
await publishAfterLog();
|
||||||
await refreshSlots();
|
await refreshSlots();
|
||||||
if (mounted) showFillStatus('Fill all failed: $error');
|
if (mounted) {
|
||||||
|
showFillStatus((
|
||||||
|
label: 'Fill all failed: $error',
|
||||||
|
kind: CueKind.error,
|
||||||
|
count: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -59,13 +81,17 @@ mixin LogMealFillMixin<T extends StatefulWidget>
|
|||||||
await _afterFill(outcome.written, later: day != null);
|
await _afterFill(outcome.written, later: day != null);
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showFillStatus(outcome.message);
|
showFillStatus(fillCue(outcome, armed: fillFlow.armedCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disarms a proposal without writing anything.
|
/// Disarms a proposal without writing anything.
|
||||||
void onFillCancel() {
|
void onFillCancel() {
|
||||||
fillFlow.cancel();
|
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
|
/// Publishes the batch once and drops the filled dishes from the delivery
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import 'package:diet_guard_app/models/slot.dart';
|
|||||||
import 'package:diet_guard_app/services/log_storage_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/services/meal_schedule_service.dart';
|
||||||
import 'package:diet_guard_app/ui/theme.dart';
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
|
||||||
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
|
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@ -51,7 +52,7 @@ mixin LogMealFutureMixin<T extends StatefulWidget> on State<T> {
|
|||||||
Future<void> _pickFutureDate() async {
|
Future<void> _pickFutureDate() async {
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final tomorrow = DateTime(today.year, today.month, today.day + 1);
|
final tomorrow = DateTime(today.year, today.month, today.day + 1);
|
||||||
final picked = await showDatePicker(
|
final picked = await pickDate(
|
||||||
context: context,
|
context: context,
|
||||||
firstDate: tomorrow,
|
firstDate: tomorrow,
|
||||||
lastDate: tomorrow.add(const Duration(days: 365)),
|
lastDate: tomorrow.add(const Duration(days: 365)),
|
||||||
@ -91,21 +92,34 @@ mixin LogMealFutureMixin<T extends StatefulWidget> on State<T> {
|
|||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.event, size: 18, color: scheme.onSurfaceVariant),
|
// Today is the default and shows nothing; only a picked date (data)
|
||||||
const SizedBox(width: AppSpacing.xs),
|
// is shown, marked by the calendar glyph.
|
||||||
Expanded(child: Text(date == null ? 'Today' : localDateKey(date))),
|
if (date != null) ...[
|
||||||
TextButton.icon(
|
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
|
// Not Icons.calendar_month: the app bar's own "Calendar" nav button
|
||||||
// already uses that, and a second one on screen makes
|
// already uses that, and a second one on screen makes
|
||||||
// `find.byIcon` ambiguous for widget tests (and taps) alike.
|
// `find.byIcon` ambiguous for widget tests (and taps) alike.
|
||||||
icon: const Icon(Icons.date_range, size: 18),
|
icon: Icon(
|
||||||
label: Text(date == null ? 'Log for later' : 'Change date'),
|
Icons.date_range,
|
||||||
|
size: 18,
|
||||||
|
semanticLabel: date == null ? 'Log for later' : 'Change date',
|
||||||
|
),
|
||||||
onPressed: _pickFutureDate,
|
onPressed: _pickFutureDate,
|
||||||
),
|
),
|
||||||
if (date != null)
|
if (date != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close, semanticLabel: 'Back to today'),
|
||||||
tooltip: 'Back to today',
|
|
||||||
onPressed: resetFutureLog,
|
onPressed: resetFutureLog,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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/kuchnia_spread.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_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/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:diet_guard_app/widgets/macro_input_row.dart';
|
||||||
import 'package:flutter/material.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.
|
/// Writes [dish]'s name and macros into the log form's controllers.
|
||||||
///
|
///
|
||||||
/// Free function rather than a mixin method so the numeric formatting is
|
/// 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
|
/// not be re-offered) and still records the fetch, so a successful tap
|
||||||
/// also spares the automatic path a second walk.
|
/// also spares the automatic path a second walk.
|
||||||
///
|
///
|
||||||
/// Returns a line to show the user, or null when a dish was prefilled and
|
/// Returns the cue to show (a spoken line, a glyph kind and a dish count),
|
||||||
/// the form speaks for itself.
|
/// or null when a dish was prefilled and the form speaks for itself.
|
||||||
Future<String?> loadDeliveryOnTap() async {
|
Future<CountCue?> loadDeliveryOnTap() async {
|
||||||
if (!KuchniaQueueService.isInitialized) return 'Catering is not ready yet.';
|
if (!KuchniaQueueService.isInitialized) {
|
||||||
|
return _cue('Catering is not ready yet.', CueKind.warn);
|
||||||
|
}
|
||||||
final logged = await LogStorageService.instance.todayEntries();
|
final logged = await LogStorageService.instance.todayEntries();
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
final result = await refreshDelivery(DateTime.now());
|
final result = await refreshDelivery(DateTime.now());
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
if (!result.ok) return result.reason;
|
if (!result.ok) return _cue(result.reason!, CueKind.error);
|
||||||
if (result.dishes.isEmpty) return 'No delivery today.';
|
if (result.dishes.isEmpty) {
|
||||||
|
return _cue('No delivery today.', CueKind.warn, 0);
|
||||||
|
}
|
||||||
await KuchniaQueueService.instance.recordFetched(DateTime.now());
|
await KuchniaQueueService.instance.recordFetched(DateTime.now());
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
final ordered = dishesInSlotOrder(
|
final ordered = dishesInSlotOrder(
|
||||||
@ -161,7 +168,7 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (KuchniaQueueService.next == null) {
|
if (KuchniaQueueService.next == null) {
|
||||||
return 'All delivered dishes are already logged.';
|
return _cue('All delivered dishes are already logged.', CueKind.ok, 0);
|
||||||
}
|
}
|
||||||
prefillNextDish();
|
prefillNextDish();
|
||||||
return null;
|
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.
|
/// the 250-line cap, and this is the mixin's own behaviour anyway.
|
||||||
Future<void> loadDeliveryAndReport() async {
|
Future<void> loadDeliveryAndReport() async {
|
||||||
setState(() => deliveryBusy = true);
|
setState(() => deliveryBusy = true);
|
||||||
String? message;
|
CountCue? cue;
|
||||||
try {
|
try {
|
||||||
message = await loadDeliveryOnTap();
|
cue = await loadDeliveryOnTap();
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => deliveryBusy = false);
|
if (mounted) setState(() => deliveryBusy = false);
|
||||||
}
|
}
|
||||||
if (!mounted || message == null) return;
|
if (!mounted || cue == null) return;
|
||||||
ScaffoldMessenger.of(
|
// 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,
|
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.
|
/// Prefills the form with the next queued dish, if there is one.
|
||||||
|
|||||||
@ -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_feedback.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_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/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/ui/theme.dart';
|
||||||
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
|
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
|
||||||
import 'package:diet_guard_app/widgets/fill_all_row.dart';
|
import 'package:diet_guard_app/widgets/fill_all_row.dart';
|
||||||
@ -59,9 +60,14 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
@override
|
@override
|
||||||
int? selectedSlot;
|
int? selectedSlot;
|
||||||
String _source = 'manual';
|
String _source = 'manual';
|
||||||
String? _status;
|
CueState? _cue; // painted as a tinted icon, never as text
|
||||||
TodayProgress? _progress;
|
TodayProgress? _progress;
|
||||||
|
|
||||||
|
void _setCue(CueKind kind, String? label, [int? count]) {
|
||||||
|
_cue = label == null ? null : CueState.next(_cue, kind, label, count);
|
||||||
|
_progress = null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -117,10 +123,8 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
@override
|
@override
|
||||||
void onDishPrefilled() => setState(() => _source = 'catering');
|
void onDishPrefilled() => setState(() => _source = 'catering');
|
||||||
@override
|
@override
|
||||||
void showFillStatus(String message) => setState(() {
|
void showFillStatus(FillCue cue) =>
|
||||||
_status = message;
|
setState(() => _setCue(cue.kind, cue.label, cue.count));
|
||||||
_progress = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
void _onSuggestionSelected(FoodSuggestion suggestion) {
|
void _onSuggestionSelected(FoodSuggestion suggestion) {
|
||||||
fillControllersFromSuggestion(suggestion, _descController, _macros);
|
fillControllersFromSuggestion(suggestion, _descController, _macros);
|
||||||
@ -133,18 +137,12 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
Future<void> _onLogMeal() async {
|
Future<void> _onLogMeal() async {
|
||||||
final desc = _descController.text.trim();
|
final desc = _descController.text.trim();
|
||||||
if (desc.isEmpty) {
|
if (desc.isEmpty) {
|
||||||
setState(() {
|
setState(() => _setCue(CueKind.error, 'Type what you ate first.'));
|
||||||
_status = 'Type what you ate first.';
|
|
||||||
_progress = null;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final target = resolveLogTarget(selectedSlot);
|
final target = resolveLogTarget(selectedSlot);
|
||||||
if (target.error != null) {
|
if (target.error != null) {
|
||||||
setState(() {
|
setState(() => _setCue(CueKind.error, target.error));
|
||||||
_status = target.error;
|
|
||||||
_progress = null;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// On the tap, before the write/sync: waiting for them is just latency.
|
// 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.
|
// becomes a dead letter -- the exact regression the PC gate once shipped.
|
||||||
advanceQueueAfterLog(desc);
|
advanceQueueAfterLog(desc);
|
||||||
setState(() {
|
setState(() {
|
||||||
_status = queueStatusLine;
|
_setCue(CueKind.info, queueStatusLine);
|
||||||
_progress = buildTodayProgress(log, logged: entry);
|
_progress = buildTodayProgress(log, logged: entry);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -180,7 +178,8 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
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: [
|
actions: [
|
||||||
...logMealAppBarActions(
|
...logMealAppBarActions(
|
||||||
onFoodBank: onOpenFoodBank,
|
onFoodBank: onOpenFoodBank,
|
||||||
@ -206,9 +205,10 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
setState(() => selectedSlot = slot),
|
setState(() => selectedSlot = slot),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
IconField(
|
||||||
controller: _descController,
|
controller: _descController,
|
||||||
decoration: const InputDecoration(labelText: 'What did you eat?'),
|
icon: Icons.fastfood,
|
||||||
|
semanticLabel: 'What did you eat?',
|
||||||
),
|
),
|
||||||
AutocompleteSuggestionList(
|
AutocompleteSuggestionList(
|
||||||
suggestions: _suggestions,
|
suggestions: _suggestions,
|
||||||
@ -231,12 +231,12 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
busy: fillFlow.busy || deliveryBusy,
|
busy: fillFlow.busy || deliveryBusy,
|
||||||
armedCount: fillFlow.armedCount,
|
armedCount: fillFlow.armedCount,
|
||||||
),
|
),
|
||||||
// Mutually exclusive: _status carries a validation complaint,
|
// Mutually exclusive: _cue carries a validation complaint or
|
||||||
// _progress the post-log summary. A successful log clears one
|
// queue note, _progress the post-log summary; the two may stack
|
||||||
// and sets the other, so the two never stack.
|
// only after a log that leaves dishes queued.
|
||||||
if (_status != null) ...[
|
if (_cue != null) ...[
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
Text(_status!),
|
_cue!.widget,
|
||||||
],
|
],
|
||||||
if (_progress != null) ...[
|
if (_progress != null) ...[
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
|||||||
@ -7,11 +7,18 @@
|
|||||||
part of 'settings_screen.dart';
|
part of 'settings_screen.dart';
|
||||||
|
|
||||||
mixin _SettingsKcalGoal on State<SettingsScreen> {
|
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;
|
if (!mounted) return;
|
||||||
setState(() => _status = message);
|
setState(
|
||||||
|
() => _status = StatusCue(
|
||||||
|
key: UniqueKey(),
|
||||||
|
kind: kind,
|
||||||
|
semanticLabel: semanticLabel,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer? _kcalGoalDebounce;
|
Timer? _kcalGoalDebounce;
|
||||||
@ -50,13 +57,13 @@ mixin _SettingsKcalGoal on State<SettingsScreen> {
|
|||||||
() => Permission.ignoreBatteryOptimizations.request();
|
() => Permission.ignoreBatteryOptimizations.request();
|
||||||
try {
|
try {
|
||||||
final status = await request();
|
final status = await request();
|
||||||
_showMessage(
|
if (status.isGranted) {
|
||||||
status.isGranted
|
_showCue(CueKind.ok, 'Battery optimization exemption granted.');
|
||||||
? 'Battery optimization exemption granted.'
|
} else {
|
||||||
: 'Exemption not granted -- notifications may be delayed.',
|
_showCue(CueKind.warn, 'Exemption not granted.');
|
||||||
);
|
}
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
_showMessage('Could not request exemption: $e');
|
_showCue(CueKind.error, 'Could not request exemption: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,21 +18,39 @@ library;
|
|||||||
import 'package:diet_guard_app/services/kuchnia_client.dart';
|
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_credential_service.dart';
|
||||||
import 'package:diet_guard_app/services/kuchnia_import.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:diet_guard_app/ui/theme.dart';
|
||||||
import 'package:flutter/material.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.
|
/// 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
|
/// 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.
|
/// 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());
|
final result = await refreshDelivery(DateTime.now());
|
||||||
if (!result.ok) return result.reason!;
|
if (!result.ok) return FetchOutcome(CueKind.error, result.reason!);
|
||||||
if (result.dishes.isEmpty) return 'No delivery found for today.';
|
if (result.dishes.isEmpty) {
|
||||||
|
return const FetchOutcome(CueKind.warn, 'No delivery found for today.');
|
||||||
|
}
|
||||||
final count = result.dishes.length;
|
final count = result.dishes.length;
|
||||||
return '$count ${count == 1 ? 'dish' : 'dishes'} added to your food bank. '
|
return FetchOutcome(CueKind.ok, '$count dishes added', count: count);
|
||||||
'Log them from the meal screen when you eat them.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Credential fields plus, on Android, a "fetch today's delivery" action.
|
/// 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
|
/// button is replaced by a one-line explanation rather than silently
|
||||||
/// vanishing. [onFetch] defaults to the real fetch. Both are injectable so a
|
/// 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.
|
/// widget test can drive the section without a network or a platform check.
|
||||||
const KuchniaSettingsSection({
|
const KuchniaSettingsSection({this.canFetch, this.onFetch, super.key});
|
||||||
this.canFetch,
|
|
||||||
this.onFetch,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Whether this platform can reach the catering panel; null means ask.
|
/// Whether this platform can reach the catering panel; null means ask.
|
||||||
final bool? canFetch;
|
final bool? canFetch;
|
||||||
|
|
||||||
/// Fetches today's delivery and returns a status line; null means the real
|
/// Fetches today's delivery and returns a status line; null means the real
|
||||||
/// one.
|
/// one.
|
||||||
final Future<String> Function()? onFetch;
|
final Future<FetchOutcome> Function()? onFetch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<KuchniaSettingsSection> createState() => _KuchniaSettingsSectionState();
|
State<KuchniaSettingsSection> createState() => _KuchniaSettingsSectionState();
|
||||||
@ -69,7 +83,7 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
|
|||||||
);
|
);
|
||||||
bool _obscured = true;
|
bool _obscured = true;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _status;
|
Widget? _status;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -85,7 +99,13 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
|
|||||||
_password.text,
|
_password.text,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
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 {
|
Future<void> _fetch() async {
|
||||||
@ -93,62 +113,65 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
|
|||||||
_busy = true;
|
_busy = true;
|
||||||
_status = null;
|
_status = null;
|
||||||
});
|
});
|
||||||
final message = await (widget.onFetch ?? fetchTodaysDelivery)();
|
final outcome = await (widget.onFetch ?? fetchTodaysDelivery)();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = false;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
final canFetch = widget.canFetch ?? kuchniaFetchSupported;
|
final canFetch = widget.canFetch ?? kuchniaFetchSupported;
|
||||||
// The section owns its own leading divider so the settings screen needs a
|
// 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.
|
// 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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 8),
|
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(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
||||||
child: TextField(
|
child: IconField(
|
||||||
|
icon: Icons.alternate_email,
|
||||||
|
semanticLabel: 'Panel e-mail',
|
||||||
controller: _username,
|
controller: _username,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
decoration: const InputDecoration(labelText: 'Panel e-mail'),
|
|
||||||
onSubmitted: (_) => _save(),
|
onSubmitted: (_) => _save(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
||||||
child: TextField(
|
child: IconField(
|
||||||
|
icon: Icons.key,
|
||||||
|
semanticLabel: 'Panel password',
|
||||||
controller: _password,
|
controller: _password,
|
||||||
obscureText: _obscured,
|
obscure: _obscured,
|
||||||
autocorrect: false,
|
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(),
|
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),
|
const SizedBox(height: 12),
|
||||||
@ -156,29 +179,27 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
FilledButton(onPressed: _save, child: const Text('Save login')),
|
IconButton.filled(
|
||||||
if (canFetch)
|
onPressed: _save,
|
||||||
OutlinedButton.icon(
|
icon: const Icon(Icons.check, semanticLabel: 'Save login'),
|
||||||
onPressed: _busy ? null : _fetch,
|
),
|
||||||
icon: const Icon(Icons.restaurant),
|
// Browser builds show the button greyed out: the panel blocks
|
||||||
label: Text(
|
// browser requests, and the disabled state is the cue.
|
||||||
_busy ? 'Fetching…' : "Fetch today's delivery",
|
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) ...[
|
if (_status != null) ...[const SizedBox(height: 8), _status!],
|
||||||
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),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/firebase_client.dart';
|
||||||
import 'package:diet_guard_app/services/google_sign_in_backend.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/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/ui/theme.dart';
|
||||||
import 'package:diet_guard_app/widgets/data_backup_row.dart';
|
import 'package:diet_guard_app/widgets/data_backup_row.dart';
|
||||||
import 'package:diet_guard_app/widgets/log_sound_tile.dart';
|
import 'package:diet_guard_app/widgets/log_sound_tile.dart';
|
||||||
@ -149,30 +151,26 @@ class _SettingsScreenState extends State<SettingsScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
// No title: the gear that opened this screen already names it.
|
||||||
// Capped at the prose width: on the ~1366px desktop window the
|
appBar: AppBar(),
|
||||||
// paragraphs below ran ~180 chars/line, twice tokens.md rule 21's limit.
|
// Capped at the prose width so fields do not span a ~1366px window.
|
||||||
body: Center(
|
body: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
|
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
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
|
// A four-digit number: capped so the field does not read as a
|
||||||
// layout error by spanning the whole column.
|
// layout error by spanning the whole column.
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
||||||
child: TextField(
|
child: IconField(
|
||||||
|
icon: MacroKind.kcal.icon,
|
||||||
|
iconColor: MacroKind.kcal.color,
|
||||||
|
semanticLabel: 'Daily kcal goal',
|
||||||
controller: _kcalGoalController,
|
controller: _kcalGoalController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Daily kcal goal',
|
|
||||||
helperText: 'Shown in the history day summary',
|
|
||||||
suffixText: 'kcal',
|
|
||||||
),
|
|
||||||
onChanged: _onKcalGoalChanged,
|
onChanged: _onKcalGoalChanged,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -186,15 +184,21 @@ class _SettingsScreenState extends State<SettingsScreen>
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Sync settings'),
|
leading: const Icon(
|
||||||
subtitle: const Text('Firebase sync'),
|
Icons.cloud_sync_outlined,
|
||||||
|
semanticLabel: 'Sync settings',
|
||||||
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => unawaited(_openSyncSettings()),
|
onTap: () => unawaited(_openSyncSettings()),
|
||||||
),
|
),
|
||||||
|
// Legacy mirror: muted tint marks it as the secondary path.
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Advanced sync (GitHub)'),
|
leading: Icon(
|
||||||
subtitle: const Text('Cutover mirror — not recommended'),
|
Icons.code,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
semanticLabel: 'Advanced sync (GitHub)',
|
||||||
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => unawaited(_openGitHubMirror()),
|
onTap: () => unawaited(_openGitHubMirror()),
|
||||||
),
|
),
|
||||||
@ -203,42 +207,27 @@ class _SettingsScreenState extends State<SettingsScreen>
|
|||||||
.dailyKcalGoal
|
.dailyKcalGoal
|
||||||
.toString(),
|
.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
|
// Battery-optimization exemption is Android-only, and
|
||||||
// `permission_handler` has no web implementation at all;
|
// `permission_handler` has no web implementation at all;
|
||||||
// the row would throw rather than degrade in the browser.
|
// the row would throw rather than degrade in the browser.
|
||||||
if (_isAndroid) ...[
|
if (_isAndroid) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 24),
|
||||||
OutlinedButton.icon(
|
const Divider(),
|
||||||
onPressed: _requestBatteryExemption,
|
const SizedBox(height: 8),
|
||||||
icon: const Icon(Icons.battery_alert),
|
Align(
|
||||||
label: const Text('Disable battery optimization'),
|
alignment: Alignment.centerLeft,
|
||||||
|
child: IconButton.outlined(
|
||||||
|
onPressed: _requestBatteryExemption,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.battery_alert,
|
||||||
|
semanticLabel: 'Disable battery optimization',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_status != null) ...[
|
if (_status != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
|
Align(alignment: Alignment.centerLeft, child: _status),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -22,13 +22,6 @@ export 'package:diet_guard_app/models/workout_session.dart';
|
|||||||
/// Formula names, in display order.
|
/// Formula names, in display order.
|
||||||
const List<String> activityFormulas = ['MET', 'ACSM', 'Per-km'];
|
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.
|
/// Measured activity averages at most the last two full weeks.
|
||||||
const int maxWindowDays = 14;
|
const int maxWindowDays = 14;
|
||||||
|
|
||||||
|
|||||||
@ -31,16 +31,6 @@ const List<(double, String)> _bmiBands = [
|
|||||||
];
|
];
|
||||||
const String _topBand = 'obese class III';
|
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 %.
|
/// The lean-mass formula the calorie table switches to with a body fat %.
|
||||||
const String katchMcArdle = 'Katch-McArdle';
|
const String katchMcArdle = 'Katch-McArdle';
|
||||||
|
|
||||||
|
|||||||
@ -20,8 +20,9 @@ abstract class NotificationBackend {
|
|||||||
/// of the app.
|
/// of the app.
|
||||||
Future<bool?> requestPermission();
|
Future<bool?> requestPermission();
|
||||||
|
|
||||||
/// Posts (or replaces) the notification for [slot].
|
/// Posts (or replaces) the notification for [slot], titled with the slot's
|
||||||
Future<void> show(int slot, String title, String body);
|
/// clock time and carrying no body text.
|
||||||
|
Future<void> show(int slot, String title);
|
||||||
|
|
||||||
/// Removes the notification for [slot], if one is showing.
|
/// Removes the notification for [slot], if one is showing.
|
||||||
Future<void> cancel(int slot);
|
Future<void> cancel(int slot);
|
||||||
|
|||||||
@ -15,7 +15,8 @@ class LocalNotificationsBackend implements NotificationBackend {
|
|||||||
final FlutterLocalNotificationsPlugin plugin;
|
final FlutterLocalNotificationsPlugin plugin;
|
||||||
|
|
||||||
static const _channelId = 'diet_guard_due_slot';
|
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
|
@override
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
@ -23,6 +24,20 @@ class LocalNotificationsBackend implements NotificationBackend {
|
|||||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||||
);
|
);
|
||||||
await plugin.initialize(settings: settings);
|
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.
|
/// Requests Android 13+'s runtime `POST_NOTIFICATIONS` permission.
|
||||||
@ -47,7 +62,7 @@ class LocalNotificationsBackend implements NotificationBackend {
|
|||||||
Future.value();
|
Future.value();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> show(int slot, String title, String body) async {
|
Future<void> show(int slot, String title) async {
|
||||||
const details = NotificationDetails(
|
const details = NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
_channelId,
|
_channelId,
|
||||||
@ -56,12 +71,7 @@ class LocalNotificationsBackend implements NotificationBackend {
|
|||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await plugin.show(
|
await plugin.show(id: slot, title: title, notificationDetails: details);
|
||||||
id: slot,
|
|
||||||
title: title,
|
|
||||||
body: body,
|
|
||||||
notificationDetails: details,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -36,13 +36,13 @@ class WebNotificationBackend implements NotificationBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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
|
// Posting without permission throws; a silent no-op matches this
|
||||||
// service's degrade-quietly stance everywhere else.
|
// service's degrade-quietly stance everywhere else.
|
||||||
if (!_granted) return;
|
if (!_granted) return;
|
||||||
_shown[slot] = web.Notification(
|
_shown[slot] = web.Notification(
|
||||||
title,
|
title,
|
||||||
web.NotificationOptions(body: body, tag: 'diet_guard_slot_$slot'),
|
web.NotificationOptions(tag: 'diet_guard_slot_$slot'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,11 +86,9 @@ class NotificationService {
|
|||||||
await _backend.cancel(id);
|
await _backend.cancel(id);
|
||||||
}
|
}
|
||||||
for (final slot in due) {
|
for (final slot in due) {
|
||||||
await _backend.show(
|
// The title is only the overdue slot's clock time (a number from the
|
||||||
slot,
|
// user's own schedule); the launcher icon is the second channel.
|
||||||
'Meal not logged',
|
await _backend.show(slot, slotLabel(slot));
|
||||||
"You haven't logged your ${slotLabel(slot)} meal yet.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,8 +53,7 @@ abstract final class SyncHealth {
|
|||||||
_recordFailure(SyncFailureKind.unconfigured);
|
_recordFailure(SyncFailureKind.unconfigured);
|
||||||
|
|
||||||
/// Marks a tick that had a backend but failed to publish.
|
/// Marks a tick that had a backend but failed to publish.
|
||||||
static Future<void> recordFailure() =>
|
static Future<void> recordFailure() => _recordFailure(SyncFailureKind.failed);
|
||||||
_recordFailure(SyncFailureKind.failed);
|
|
||||||
|
|
||||||
static Future<void> _recordFailure(SyncFailureKind kind) =>
|
static Future<void> _recordFailure(SyncFailureKind kind) =>
|
||||||
_withPrefs((prefs) => prefs.setString(_kFailureKind, kind.name));
|
_withPrefs((prefs) => prefs.setString(_kFailureKind, kind.name));
|
||||||
@ -155,6 +154,16 @@ class SyncHealthStatus {
|
|||||||
return 'Not syncing since ${_ago(since)}. Meals stay on this device.';
|
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) {
|
static String _ago(Duration since) {
|
||||||
if (since.inDays >= 1) {
|
if (since.inDays >= 1) {
|
||||||
return '${since.inDays}d ago';
|
return '${since.inDays}d ago';
|
||||||
|
|||||||
246
app/lib/ui/cues.dart
Normal file
246
app/lib/ui/cues.dart
Normal 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
129
app/lib/ui/icon_field.dart
Normal 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);
|
||||||
|
}
|
||||||
80
app/lib/ui/macro_kind.dart
Normal file
80
app/lib/ui/macro_kind.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/lib/ui/source_icon.dart
Normal file
33
app/lib/ui/source_icon.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -195,6 +195,9 @@ abstract final class AppWidth {
|
|||||||
/// value reads as a layout error rather than an affordance.
|
/// value reads as a layout error rather than an affordance.
|
||||||
static const double field = 420;
|
static const double field = 420;
|
||||||
|
|
||||||
|
/// 440px — cap for a date/time picker dialog (7 day columns + year wheel).
|
||||||
|
static const double picker = 440;
|
||||||
|
|
||||||
/// Viewport height below which the layout should switch to its compact form.
|
/// Viewport height below which the layout should switch to its compact form.
|
||||||
///
|
///
|
||||||
/// 700px, i.e. 768 minus typical window chrome: the supported floor is a
|
/// 700px, i.e. 768 minus typical window chrome: the supported floor is a
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/food_suggestion.dart';
|
import 'package:diet_guard_app/models/food_suggestion.dart';
|
||||||
|
import 'package:diet_guard_app/ui/macro_kind.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// A tappable list of [FoodSuggestion]s, each filling the form on tap.
|
/// A tappable list of [FoodSuggestion]s, each filling the form on tap.
|
||||||
@ -57,8 +58,12 @@ class AutocompleteSuggestionList extends StatelessWidget {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(suggestion.name),
|
title: Text(suggestion.name),
|
||||||
subtitle: Text(
|
subtitle: Align(
|
||||||
'${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal',
|
alignment: Alignment.centerLeft,
|
||||||
|
child: MacroFigure(
|
||||||
|
kind: MacroKind.kcal,
|
||||||
|
value: suggestion.nutrition.kcal.toStringAsFixed(0),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onTap: () => onSelected(suggestion),
|
onTap: () => onSelected(suggestion),
|
||||||
);
|
);
|
||||||
@ -76,17 +81,34 @@ class AutocompleteSuggestionList extends StatelessWidget {
|
|||||||
onTap: () => onSelected(suggestion),
|
onTap: () => onSelected(suggestion),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Text(
|
child: Row(
|
||||||
'${suggestion.name} · '
|
children: [
|
||||||
'${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal',
|
Flexible(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
|
suggestion.name,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
MacroFigure(
|
||||||
|
kind: MacroKind.kcal,
|
||||||
|
value: suggestion.nutrition.kcal.toStringAsFixed(0),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (remaining > 0)
|
if (remaining > 0)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _showMore(context),
|
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'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,23 +2,23 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/period_average.dart';
|
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Displays this week's and this month's average kcal/day and their bands.
|
/// 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):
|
/// Mirrors `_calendar_view.py`'s `averages_text`, minus the words: each period
|
||||||
/// week NNNN kcal (band) · month NNNN kcal (band)".
|
/// 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
|
/// 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
|
/// 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.
|
/// doing right now", which is what the Today progress card is for.
|
||||||
class AverageSummaryRow extends StatelessWidget {
|
class AverageSummaryRow extends StatelessWidget {
|
||||||
/// Creates an [AverageSummaryRow].
|
/// Creates an [AverageSummaryRow].
|
||||||
const AverageSummaryRow({
|
const AverageSummaryRow({required this.week, required this.month, super.key});
|
||||||
required this.week,
|
|
||||||
required this.month,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The current ISO week's average, through yesterday.
|
/// The current ISO week's average, through yesterday.
|
||||||
final PeriodAverage week;
|
final PeriodAverage week;
|
||||||
@ -26,18 +26,72 @@ class AverageSummaryRow extends StatelessWidget {
|
|||||||
/// The current calendar month's average, through yesterday.
|
/// The current calendar month's average, through yesterday.
|
||||||
final PeriodAverage month;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Text(
|
return Row(
|
||||||
'Avg/day (to yesterday): week ${_part(week)} · month ${_part(month)}',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
_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),
|
||||||
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,12 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/services/activity_kcal.dart';
|
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/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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// How many of the newest sessions to list.
|
/// How many of the newest sessions to list.
|
||||||
@ -55,48 +59,118 @@ class ActivityCard extends StatelessWidget {
|
|||||||
final lastDay = steps.isEmpty
|
final lastDay = steps.isEmpty
|
||||||
? null
|
? null
|
||||||
: steps.keys.reduce((a, b) => a.compareTo(b) > 0 ? a : b);
|
: steps.keys.reduce((a, b) => a.compareTo(b) > 0 ? a : b);
|
||||||
|
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
return BodySection(
|
return BodySection(
|
||||||
title: 'Activity',
|
semanticLabel: 'Activity',
|
||||||
children: [
|
children: [
|
||||||
if (sessions == null && steps.isEmpty)
|
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) ...[
|
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)
|
for (final name in activityFormulas)
|
||||||
BodyStatRow(name, '${whole(means[name] ?? 0)} kcal/day'),
|
_FormulaRow(name, whole(means[name] ?? 0)),
|
||||||
],
|
],
|
||||||
if (lastDay != null && stepKcal != null)
|
if (lastDay != null && stepKcal != null)
|
||||||
BodyNote(
|
Padding(
|
||||||
'Steps outside workouts: ${steps[lastDay]} on $lastDay; above '
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||||
'$stepBaseline/day they add ${whole(stepKcal)} kcal/day '
|
child: Row(
|
||||||
'(included above).',
|
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)
|
if (onConnectSteps != null)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton(
|
||||||
icon: const Icon(Icons.directions_walk),
|
|
||||||
label: const Text('Connect Health Connect steps'),
|
|
||||||
onPressed: onConnectSteps,
|
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)
|
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) ...[
|
if (sessions != null && kg != null) ...[
|
||||||
const Divider(),
|
const Divider(),
|
||||||
if (newest.isEmpty)
|
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))
|
for (final session in newest.take(activityListLimit))
|
||||||
_SessionTile(result: sessionKcal(session, kg)),
|
_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 {
|
class _SessionTile extends StatelessWidget {
|
||||||
const _SessionTile({required this.result});
|
const _SessionTile({required this.result});
|
||||||
|
|
||||||
@ -106,22 +180,67 @@ class _SessionTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = result.session;
|
final session = result.session;
|
||||||
final km = session.km;
|
final km = session.km;
|
||||||
final distance = km == null ? '' : ', ${oneDecimal(km)} km';
|
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
final kcal = [
|
return Padding(
|
||||||
for (final name in activityFormulas)
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||||
_formulaKcal(name, result.kcal[name]!, result.fallback.contains(name)),
|
child: Column(
|
||||||
].join(' · ');
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
return ListTile(
|
children: [
|
||||||
dense: true,
|
Row(
|
||||||
contentPadding: EdgeInsets.zero,
|
children: [
|
||||||
title: Text('${session.day} ${session.label}'),
|
Text(session.day),
|
||||||
subtitle: Text(
|
const SizedBox(width: AppSpacing.sm),
|
||||||
'${whole(session.minutes)} min$distance\n$kcal kcal',
|
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 ? '~' : ''}';
|
|
||||||
|
|||||||
@ -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;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/services/body_calc.dart';
|
import 'package:diet_guard_app/services/body_calc.dart';
|
||||||
import 'package:diet_guard_app/ui/theme.dart';
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
import 'package:flutter/material.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 {
|
class BodySection extends StatelessWidget {
|
||||||
/// Creates a section.
|
/// Creates a section.
|
||||||
const BodySection({
|
const BodySection({
|
||||||
required this.title,
|
required this.semanticLabel,
|
||||||
required this.children,
|
required this.children,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Section heading.
|
/// Spoken name of the section.
|
||||||
final String title;
|
final String semanticLabel;
|
||||||
|
|
||||||
/// Section content.
|
/// Section content.
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
return Semantics(
|
||||||
return Card(
|
container: true,
|
||||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
label: semanticLabel,
|
||||||
child: Padding(
|
child: Card(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||||
child: Column(
|
child: Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
children: [
|
child: Column(
|
||||||
Text(title, style: theme.textTheme.titleMedium),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: AppSpacing.sm),
|
children: 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 {
|
class BodyStatRow extends StatelessWidget {
|
||||||
/// Creates a row.
|
/// A row led by a formula's proper name.
|
||||||
const BodyStatRow(this.label, this.value, {this.color, super.key});
|
const BodyStatRow.named(this.name, this.value, {this.color, super.key})
|
||||||
|
: icon = null,
|
||||||
|
semanticLabel = null;
|
||||||
|
|
||||||
/// What the value is.
|
/// A row led by [icon], spoken as [semanticLabel].
|
||||||
final String label;
|
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.
|
/// The formatted value.
|
||||||
final String value;
|
final String value;
|
||||||
@ -56,11 +78,19 @@ class BodyStatRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final lead = icon;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2),
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text(label)),
|
Expanded(
|
||||||
|
child: lead == null
|
||||||
|
? Text(name!)
|
||||||
|
: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Icon(lead, size: 20, semanticLabel: semanticLabel),
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@ -74,28 +104,8 @@ class BodyStatRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A muted one-line explanation (missing data, notes).
|
/// `74.99` -> `"75.0 kg"`, null -> a dash. Half-up, like the PC.
|
||||||
class BodyNote extends StatelessWidget {
|
String formatKg(double? kg) => kg == null ? '—' : '${oneDecimal(kg)} kg';
|
||||||
/// 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';
|
|
||||||
|
|
||||||
/// [value] to one decimal, rounded half-up like the PC prints it.
|
/// [value] to one decimal, rounded half-up like the PC prints it.
|
||||||
String oneDecimal(double value) => halfUp(value, 1).toStringAsFixed(1);
|
String oneDecimal(double value) => halfUp(value, 1).toStringAsFixed(1);
|
||||||
|
|||||||
@ -2,9 +2,26 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/services/body_calc.dart';
|
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:diet_guard_app/widgets/body/body_section.dart';
|
||||||
import 'package:flutter/material.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.
|
/// BMI, BMI Prime, Trefethen's BMI and the WHO category.
|
||||||
class BmiCard extends StatelessWidget {
|
class BmiCard extends StatelessWidget {
|
||||||
/// Creates the card for [bio].
|
/// Creates the card for [bio].
|
||||||
@ -17,30 +34,40 @@ class BmiCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final report = bmiReport(bio);
|
final report = bmiReport(bio);
|
||||||
final (low, high) = healthyRange(bio);
|
final (low, high) = healthyRange(bio);
|
||||||
final normal = report.category == 'normal';
|
final (icon, kind) = bmiBandCue(report.category);
|
||||||
|
final tint = kind.tint(context);
|
||||||
return BodySection(
|
return BodySection(
|
||||||
title: 'BMI',
|
semanticLabel: 'BMI',
|
||||||
children: [
|
children: [
|
||||||
BodyStatRow(
|
Row(
|
||||||
'BMI',
|
children: [
|
||||||
oneDecimal(report.bmi),
|
Icon(icon, color: tint, semanticLabel: report.category),
|
||||||
color: normal ? null : Theme.of(context).colorScheme.error,
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
oneDecimal(report.bmi),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.headlineMedium?.copyWith(color: tint),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
BodyStatRow('Category', report.category),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
BodyStatRow('BMI Prime', halfUp(report.prime, 2).toStringAsFixed(2)),
|
BodyStatRow.named(
|
||||||
BodyStatRow('Trefethen (new BMI)', oneDecimal(report.trefethen)),
|
'BMI Prime',
|
||||||
BodyStatRow(
|
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)',
|
'Healthy weight for your height (BMI 18.5-24.9)',
|
||||||
'${oneDecimal(low)}-${oneDecimal(high)} kg',
|
'${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 {
|
class IdealWeightCard extends StatelessWidget {
|
||||||
/// Creates the card for [bio].
|
/// Creates the card for [bio].
|
||||||
const IdealWeightCard({required this.bio, super.key});
|
const IdealWeightCard({required this.bio, super.key});
|
||||||
@ -51,25 +78,25 @@ class IdealWeightCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final formulas = idealWeights(bio);
|
final formulas = idealWeights(bio);
|
||||||
final undefined = formulas.any((f) => f.value == null);
|
|
||||||
final summary = idealSummary(formulas);
|
final summary = idealSummary(formulas);
|
||||||
return BodySection(
|
return BodySection(
|
||||||
title: 'Ideal weight',
|
semanticLabel: 'Ideal weight',
|
||||||
children: [
|
children: [
|
||||||
for (final formula in formulas)
|
for (final formula in formulas)
|
||||||
BodyStatRow(formula.name, formatKg(formula.value)),
|
BodyStatRow.named(formula.name, formatKg(formula.value)),
|
||||||
if (summary != null) ...[
|
if (summary != null) ...[
|
||||||
const Divider(),
|
const Divider(),
|
||||||
BodyStatRow('Average of all', formatKg(summary.$1)),
|
BodyStatRow.icon(
|
||||||
BodyStatRow(
|
Icons.functions,
|
||||||
|
'Average of all',
|
||||||
|
formatKg(summary.$1),
|
||||||
|
),
|
||||||
|
BodyStatRow.icon(
|
||||||
|
Icons.swap_horiz,
|
||||||
'Ideal range',
|
'Ideal range',
|
||||||
'${oneDecimal(summary.$2)}-${oneDecimal(summary.$3)} kg',
|
'${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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BodySection(
|
return BodySection(
|
||||||
title: 'Resting metabolism (BMR)',
|
semanticLabel: 'Resting metabolism (BMR)',
|
||||||
children: [
|
children: [
|
||||||
for (final formula in bmrFormulas(bio, bodyFatPct))
|
for (final formula in bmrFormulas(bio, bodyFatPct))
|
||||||
BodyStatRow(
|
Padding(
|
||||||
formula.name,
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2),
|
||||||
formula.value == null
|
child: Row(
|
||||||
? 'n/a -- log a body-fat %'
|
children: [
|
||||||
: '${whole(formula.value!)} kcal',
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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.
|
/// screen. Tapping a tile opens the section's full card in a bottom sheet.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/ui/theme.dart';
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// One tile: a section [title], an optional headline [value], and the
|
/// One tile: a big [icon] (spoken as [semanticLabel]), an optional headline
|
||||||
/// [builder] of the full card shown in the sheet.
|
/// [value] and the [builder] of the full card shown in the sheet.
|
||||||
class BodyTileSpec {
|
class BodyTileSpec {
|
||||||
/// Creates a tile spec.
|
/// 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.
|
/// Spoken name of the section; never painted.
|
||||||
final String title;
|
final String semanticLabel;
|
||||||
|
|
||||||
|
/// The section's glyph.
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
/// Headline number for the tile (null when the section has none).
|
/// Headline number for the tile (null when the section has none).
|
||||||
final String? value;
|
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.
|
/// Builds the full card for the sheet.
|
||||||
final WidgetBuilder builder;
|
final WidgetBuilder builder;
|
||||||
}
|
}
|
||||||
@ -76,33 +88,58 @@ class _Tile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final value = spec.value;
|
||||||
|
final valueIcon = spec.valueIcon;
|
||||||
return Card(
|
return Card(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: InkWell(
|
child: Semantics(
|
||||||
onTap: () => _open(context),
|
button: true,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
label: spec.semanticLabel,
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
onTap: () => _open(context),
|
||||||
child: Column(
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||||
children: [
|
child: Row(
|
||||||
Text(
|
children: [
|
||||||
spec.title,
|
ExcludeSemantics(
|
||||||
maxLines: 2,
|
child: Icon(
|
||||||
overflow: TextOverflow.ellipsis,
|
spec.icon,
|
||||||
style: theme.textTheme.bodyMedium,
|
size: 36,
|
||||||
),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
if (spec.value != null)
|
|
||||||
Text(
|
|
||||||
spec.value!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
230
app/lib/widgets/body/dated_value_card.dart
Normal file
230
app/lib/widgets/body/dated_value_card.dart
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
/// 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:diet_guard_app/widgets/pickers/pickers.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 pickDate(
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/lib/widgets/body/formula_figure.dart
Normal file
65
app/lib/widgets/body/formula_figure.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
/// The Body screen's top line: "To [lose] [0.5] [kg] / [week] on
|
/// The Body screen's top line as marks instead of a sentence: direction
|
||||||
/// [Moderate] eat 2019 kcal". Every bracket is tappable and opens a picker;
|
/// arrow, amount, unit, per-period calendar, activity glyph, then a bolt and
|
||||||
/// the choice is saved (and synced) immediately. Informational only.
|
/// the daily kcal answer. Every mark is tappable and opens a picker; the
|
||||||
|
/// choice is saved (and synced) immediately. Informational only.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
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_energy.dart';
|
||||||
import 'package:diet_guard_app/services/body_prefs.dart';
|
import 'package:diet_guard_app/services/body_prefs.dart';
|
||||||
import 'package:diet_guard_app/services/body_service.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/ui/theme.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/body_section.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:diet_guard_app/widgets/body/goal_pickers.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// The goal sentence and its answer.
|
/// The goal row and its answer.
|
||||||
class GoalCard extends StatefulWidget {
|
class GoalCard extends StatefulWidget {
|
||||||
/// Creates the card.
|
/// Creates the card.
|
||||||
const GoalCard({
|
const GoalCard({
|
||||||
@ -47,26 +51,59 @@ class _GoalCardState extends State<GoalCard> {
|
|||||||
await setGoal(BodyService.instance, picked);
|
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;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return InkWell(
|
return Semantics(
|
||||||
onTap: () => unawaited(_pick(p)),
|
button: true,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
label: spoken,
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
onTap: () => unawaited(_pick(picker)),
|
||||||
child: Text(
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
text,
|
child: Container(
|
||||||
style: TextStyle(
|
padding: const EdgeInsets.symmetric(
|
||||||
color: scheme.primary,
|
horizontal: AppSpacing.sm,
|
||||||
fontWeight: FontWeight.w600,
|
vertical: AppSpacing.xs,
|
||||||
decoration: TextDecoration.underline,
|
),
|
||||||
decorationColor: scheme.primary,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@ -76,51 +113,81 @@ class _GoalCardState extends State<GoalCard> {
|
|||||||
final tdee = target.tdee;
|
final tdee = target.tdee;
|
||||||
final maintain = _goal.direction == 'maintain';
|
final maintain = _goal.direction == 'maintain';
|
||||||
return BodySection(
|
return BodySection(
|
||||||
title: 'Daily calorie target',
|
semanticLabel: 'Daily calorie target',
|
||||||
children: [
|
children: [
|
||||||
Wrap(
|
Wrap(
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
runSpacing: AppSpacing.xs,
|
spacing: AppSpacing.sm,
|
||||||
|
runSpacing: AppSpacing.sm,
|
||||||
children: [
|
children: [
|
||||||
const Text('To'),
|
_iconChoice(
|
||||||
_choice(_goal.direction, pickDirection),
|
_goal.direction,
|
||||||
|
directionIcon(_goal.direction),
|
||||||
|
pickDirection,
|
||||||
|
),
|
||||||
if (!maintain) ...[
|
if (!maintain) ...[
|
||||||
_choice(formatG(_goal.amount), pickAmount),
|
_choice(
|
||||||
_choice(_goal.weightUnit, pickWeightUnit),
|
formatG(_goal.amount),
|
||||||
const Text('/'),
|
Text(formatG(_goal.amount)),
|
||||||
_choice(_goal.timeUnit, pickTimeUnit),
|
pickAmount,
|
||||||
],
|
),
|
||||||
const Text('on'),
|
_choice(_goal.weightUnit, Text(_goal.weightUnit), pickWeightUnit),
|
||||||
_choice(activityLabel(_goal.activity), pickActivity),
|
_iconChoice(
|
||||||
if (kcal != null) ...[
|
_goal.timeUnit,
|
||||||
const Text('eat '),
|
timeUnitIcon(_goal.timeUnit),
|
||||||
Text(
|
pickTimeUnit,
|
||||||
'$kcal',
|
|
||||||
style: theme.textTheme.headlineMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
if (kcal == null)
|
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)
|
if (target.belowFloor)
|
||||||
Text(
|
const StatusCue(
|
||||||
'That is below the $minTargetKcal kcal floor.',
|
kind: CueKind.error,
|
||||||
style: TextStyle(color: theme.colorScheme.error),
|
icon: Icons.vertical_align_bottom,
|
||||||
|
semanticLabel: 'That is below the $minTargetKcal kcal floor.',
|
||||||
),
|
),
|
||||||
if (tdee != null)
|
if (tdee != null)
|
||||||
BodyNote(
|
Row(
|
||||||
'Maintenance ${whole(tdee)} kcal (${target.bmrName} resting '
|
children: [
|
||||||
'burn x activity); 1 kg of fat = ${formatG(kcalPerKg)} kcal.',
|
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.',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/lib/widgets/body/goal_icons.dart
Normal file
45
app/lib/widgets/body/goal_icons.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/services/body_energy.dart';
|
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/ui/theme.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/body/goal_icons.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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(
|
Future<String?> _sheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String title,
|
List<PickerOption> options,
|
||||||
List<(String, String, String?)> options,
|
|
||||||
String selected,
|
String selected,
|
||||||
) => showModalBottomSheet<String>(
|
) => showModalBottomSheet<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
child: ListView(
|
child: Padding(
|
||||||
shrinkWrap: true,
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
children: [
|
child: Wrap(
|
||||||
ListTile(title: Text(title)),
|
spacing: AppSpacing.sm,
|
||||||
for (final (value, label, subtitle) in options)
|
runSpacing: AppSpacing.sm,
|
||||||
ListTile(
|
children: [
|
||||||
title: Text(label),
|
for (final option in options)
|
||||||
subtitle: subtitle == null ? null : Text(subtitle),
|
_OptionButton(
|
||||||
selected: value == selected,
|
option: option,
|
||||||
trailing: value == selected ? const Icon(Icons.check) : null,
|
selected: option.value == selected,
|
||||||
onTap: () => Navigator.of(context).pop(value),
|
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.
|
/// Lose / maintain / gain.
|
||||||
Future<Goal?> pickDirection(BuildContext context, Goal goal) async {
|
Future<Goal?> pickDirection(BuildContext context, Goal goal) async {
|
||||||
final value = await _sheet(context, 'Direction', [
|
final value = await _sheet(context, [
|
||||||
for (final d in directions) (d, d, null),
|
for (final d in directions)
|
||||||
|
(value: d, spoken: d, icon: directionIcon(d), caption: null),
|
||||||
], goal.direction);
|
], goal.direction);
|
||||||
return value == null ? null : goal.copyWith(direction: value);
|
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 {
|
Future<Goal?> pickWeightUnit(BuildContext context, Goal goal) async {
|
||||||
final value = await _sheet(context, 'Weight unit', [
|
final value = await _sheet(context, [
|
||||||
for (final u in weightUnits.keys) (u, u, null),
|
for (final u in weightUnits.keys)
|
||||||
|
(value: u, spoken: u, icon: Icons.scale, caption: u),
|
||||||
], goal.weightUnit);
|
], goal.weightUnit);
|
||||||
return value == null ? null : goal.copyWith(weightUnit: value);
|
return value == null ? null : goal.copyWith(weightUnit: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per day, week, month or year.
|
/// Per day, week, month or year.
|
||||||
Future<Goal?> pickTimeUnit(BuildContext context, Goal goal) async {
|
Future<Goal?> pickTimeUnit(BuildContext context, Goal goal) async {
|
||||||
final value = await _sheet(context, 'Per', [
|
final value = await _sheet(context, [
|
||||||
for (final u in timeUnits.keys) (u, u, null),
|
for (final u in timeUnits.keys)
|
||||||
|
(value: u, spoken: u, icon: timeUnitIcon(u), caption: null),
|
||||||
], goal.timeUnit);
|
], goal.timeUnit);
|
||||||
return value == null ? null : goal.copyWith(timeUnit: value);
|
return value == null ? null : goal.copyWith(timeUnit: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The five levels, then the three measured formulas.
|
/// The five levels, then the three measured formulas.
|
||||||
Future<Goal?> pickActivity(BuildContext context, Goal goal) async {
|
Future<Goal?> pickActivity(BuildContext context, Goal goal) async {
|
||||||
final meaning = {for (final l in activityLevels) l.key: l.meaning};
|
final value = await _sheet(context, [
|
||||||
final value = await _sheet(context, 'Activity', [
|
|
||||||
for (final key in activityChoices)
|
for (final key in activityChoices)
|
||||||
(
|
(
|
||||||
key,
|
value: key,
|
||||||
activityLabel(key),
|
spoken: activityLabel(key),
|
||||||
meaning[key] ?? 'BMR x 1.2 + your real workouts and steps',
|
icon: activityIcon(key),
|
||||||
|
caption: activityCaption(key),
|
||||||
),
|
),
|
||||||
], goal.activity);
|
], goal.activity);
|
||||||
return value == null ? null : goal.copyWith(activity: value);
|
return value == null ? null : goal.copyWith(activity: value);
|
||||||
@ -95,6 +165,7 @@ class _AmountDialogState extends State<_AmountDialog> {
|
|||||||
late final _controller = TextEditingController(
|
late final _controller = TextEditingController(
|
||||||
text: formatG(widget.goal.amount),
|
text: formatG(widget.goal.amount),
|
||||||
);
|
);
|
||||||
|
bool _rejected = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -104,14 +175,17 @@ class _AmountDialogState extends State<_AmountDialog> {
|
|||||||
|
|
||||||
void _submit() {
|
void _submit() {
|
||||||
final typed = double.tryParse(_controller.text.replaceAll(',', '.'));
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final goal = widget.goal;
|
final goal = widget.goal;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('How much (${goal.weightUnit} / ${goal.timeUnit})'),
|
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -126,23 +200,29 @@ class _AmountDialogState extends State<_AmountDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
TextField(
|
IconField(
|
||||||
|
icon: Icons.edit,
|
||||||
|
semanticLabel: 'Other amount',
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
decoration: const InputDecoration(labelText: 'Other amount'),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
|
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
|
||||||
],
|
],
|
||||||
onSubmitted: (_) => _submit(),
|
onSubmitted: (_) => _submit(),
|
||||||
),
|
),
|
||||||
|
if (_rejected)
|
||||||
|
const StatusCue(kind: CueKind.error, semanticLabel: 'not a number'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
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')),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,10 @@ library;
|
|||||||
|
|
||||||
import 'package:diet_guard_app/models/local_time.dart';
|
import 'package:diet_guard_app/models/local_time.dart';
|
||||||
import 'package:diet_guard_app/services/body_service.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/ui/theme.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
@ -24,7 +26,7 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
final _height = TextEditingController();
|
final _height = TextEditingController();
|
||||||
String? _birth;
|
String? _birth;
|
||||||
String? _sex;
|
String? _sex;
|
||||||
String? _status;
|
Widget? _status;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -45,7 +47,7 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
Future<void> _pickBirth() async {
|
Future<void> _pickBirth() async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final initial = DateTime.tryParse(_birth ?? '') ?? DateTime(now.year - 30);
|
final initial = DateTime.tryParse(_birth ?? '') ?? DateTime(now.year - 30);
|
||||||
final picked = await showDatePicker(
|
final picked = await pickDate(
|
||||||
context: context,
|
context: context,
|
||||||
firstDate: DateTime(1900),
|
firstDate: DateTime(1900),
|
||||||
lastDate: now,
|
lastDate: now,
|
||||||
@ -59,7 +61,14 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final height = double.tryParse(_height.text.replaceAll(',', '.'));
|
final height = double.tryParse(_height.text.replaceAll(',', '.'));
|
||||||
if (height == null || height < 50 || height > 260) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await BodyService.instance.setProfile(
|
await BodyService.instance.setProfile(
|
||||||
@ -68,28 +77,45 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
sex: _sex,
|
sex: _sex,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _status = 'Saved.');
|
setState(
|
||||||
|
() => _status = StatusCue(
|
||||||
|
key: UniqueKey(),
|
||||||
|
kind: CueKind.ok,
|
||||||
|
semanticLabel: 'Saved.',
|
||||||
|
),
|
||||||
|
);
|
||||||
widget.onSaved();
|
widget.onSaved();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final birth = _birth;
|
||||||
|
final status = _status;
|
||||||
return BodySection(
|
return BodySection(
|
||||||
title: 'Profile',
|
semanticLabel: 'Profile',
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Align(
|
||||||
children: [
|
alignment: Alignment.centerLeft,
|
||||||
Expanded(child: Text('Born: ${_birth ?? 'not set'}')),
|
child: TextButton(
|
||||||
TextButton.icon(
|
onPressed: _pickBirth,
|
||||||
icon: const Icon(Icons.cake),
|
child: Row(
|
||||||
label: const Text('Birth date'),
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: _pickBirth,
|
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,
|
controller: _height,
|
||||||
decoration: const InputDecoration(labelText: 'Height (cm)'),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
|
FilteringTextInputFormatter.allow(RegExp('[0-9.,]')),
|
||||||
@ -97,9 +123,16 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
SegmentedButton<String>(
|
SegmentedButton<String>(
|
||||||
|
showSelectedIcon: false,
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(value: 'm', label: Text('Male')),
|
ButtonSegment(
|
||||||
ButtonSegment(value: 'f', label: Text('Female')),
|
value: 'm',
|
||||||
|
icon: Icon(Icons.male, semanticLabel: 'Male'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: 'f',
|
||||||
|
icon: Icon(Icons.female, semanticLabel: 'Female'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
selected: {?_sex},
|
selected: {?_sex},
|
||||||
emptySelectionAllowed: true,
|
emptySelectionAllowed: true,
|
||||||
@ -109,8 +142,11 @@ class _ProfileCardState extends State<ProfileCard> {
|
|||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: BodyNote(_status ?? 'Syncs to the PC.')),
|
if (status != null) Expanded(child: status) else const Spacer(),
|
||||||
FilledButton(onPressed: _save, child: const Text('Save profile')),
|
IconButton.filled(
|
||||||
|
onPressed: _save,
|
||||||
|
icon: const Icon(Icons.check, semanticLabel: 'Save profile'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -4,7 +4,10 @@
|
|||||||
/// day overrides it (a planned feast on Saturday too).
|
/// day overrides it (a planned feast on Saturday too).
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/services/body_energy.dart';
|
||||||
import 'package:diet_guard_app/services/week_plan.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/ui/theme.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -53,8 +56,7 @@ class _WeekPlanCardState extends State<WeekPlanCard> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final target = int.tryParse(_target.text) ?? widget.defaultTarget;
|
final target = int.tryParse(_target.text) ?? widget.defaultTarget;
|
||||||
final typed = <int, int>{
|
final typed = <int, int>{
|
||||||
for (var i = 0; i < 7; i++)
|
for (var i = 0; i < 7; i++) i: ?int.tryParse(_days[i].text),
|
||||||
i: ?int.tryParse(_days[i].text),
|
|
||||||
};
|
};
|
||||||
final plan = planWeek(
|
final plan = planWeek(
|
||||||
widget.today ?? DateTime.now(),
|
widget.today ?? DateTime.now(),
|
||||||
@ -64,41 +66,39 @@ class _WeekPlanCardState extends State<WeekPlanCard> {
|
|||||||
);
|
);
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return BodySection(
|
return BodySection(
|
||||||
title: 'Week planner (info only)',
|
semanticLabel: 'Week planner',
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
SpokenLabel(
|
||||||
controller: _target,
|
semanticLabel: 'Weekly average target (kcal/day)',
|
||||||
decoration: InputDecoration(
|
child: TextField(
|
||||||
labelText: 'Weekly average target (kcal/day)',
|
controller: _target,
|
||||||
hintText: '${widget.defaultTarget}',
|
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),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
for (var i = 0; i < 7; i++)
|
for (var i = 0; i < 7; i++)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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(
|
Expanded(
|
||||||
child: Text(
|
child: _DayFigure(plan.days[i], belowFloor: plan.belowFloor),
|
||||||
'${plan.days[i].kcal} kcal ${_sourceLabel(plan.days[i])}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: plan.days[i].source == 'plan' && plan.belowFloor
|
|
||||||
? scheme.error
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 96,
|
width: 96,
|
||||||
child: TextField(
|
child: IconField(
|
||||||
|
icon: Icons.rice_bowl,
|
||||||
|
semanticLabel: 'ate…',
|
||||||
controller: _days[i],
|
controller: _days[i],
|
||||||
decoration: const InputDecoration(
|
dense: true,
|
||||||
hintText: 'ate…',
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
onChanged: (_) => setState(() {}),
|
onChanged: (_) => setState(() {}),
|
||||||
@ -107,26 +107,86 @@ class _WeekPlanCardState extends State<WeekPlanCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
BodyNote(_summary(plan)),
|
_Summary(plan, scheme),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _sourceLabel(DayPlan day) => switch (day.source) {
|
/// A day's kcal with a glyph for where the number came from.
|
||||||
'typed' => '(typed)',
|
class _DayFigure extends StatelessWidget {
|
||||||
'logged' => '(logged)',
|
const _DayFigure(this.day, {required this.belowFloor});
|
||||||
'unlogged' => '(not logged -- type it)',
|
|
||||||
_ => '(plan)',
|
|
||||||
};
|
|
||||||
|
|
||||||
String _summary(WeekPlan plan) {
|
final DayPlan day;
|
||||||
final per = plan.perRemaining;
|
final bool belowFloor;
|
||||||
if (per == null) {
|
|
||||||
return 'Every day is known: week average '
|
@override
|
||||||
'${whole(plan.weekAvg)} kcal.';
|
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';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,12 +5,11 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/body_document.dart';
|
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/services/body_service.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
import 'package:diet_guard_app/widgets/body/dated_value_card.dart';
|
||||||
import 'package:diet_guard_app/widgets/body/weight_chart.dart';
|
|
||||||
import 'package:flutter/material.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.
|
/// Weight: 20-400 kg; wake-alarm weigh-ins land here too.
|
||||||
class WeightCard extends StatelessWidget {
|
class WeightCard extends StatelessWidget {
|
||||||
@ -24,12 +23,12 @@ class WeightCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final body = BodyService.instance;
|
final body = BodyService.instance;
|
||||||
return DatedValueCard(
|
return DatedValueCard(
|
||||||
title: 'Weight',
|
semanticLabel: 'Weight',
|
||||||
fieldLabel: 'Weight (kg)',
|
fieldSemanticLabel: 'Weight (kg)',
|
||||||
|
icon: Icons.monitor_weight,
|
||||||
unit: 'kg',
|
unit: 'kg',
|
||||||
min: 20,
|
min: 20,
|
||||||
max: 400,
|
max: 400,
|
||||||
hint: 'Morning weigh-ins from wake-alarm appear here.',
|
|
||||||
latest: body.latestWeight,
|
latest: body.latestWeight,
|
||||||
series: body.weightSeries,
|
series: body.weightSeries,
|
||||||
onSave: body.setWeight,
|
onSave: body.setWeight,
|
||||||
@ -51,12 +50,12 @@ class BodyFatCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final body = BodyService.instance;
|
final body = BodyService.instance;
|
||||||
return DatedValueCard(
|
return DatedValueCard(
|
||||||
title: 'Body fat',
|
semanticLabel: 'Body fat',
|
||||||
fieldLabel: 'Body fat (%)',
|
fieldSemanticLabel: 'Body fat (%)',
|
||||||
|
icon: Icons.percent,
|
||||||
unit: '%',
|
unit: '%',
|
||||||
min: minBodyFat,
|
min: minBodyFat,
|
||||||
max: maxBodyFat,
|
max: maxBodyFat,
|
||||||
hint: 'With a body fat %, targets use the lean-mass (Katch-McArdle) BMR.',
|
|
||||||
latest: body.latestBodyFat,
|
latest: body.latestBodyFat,
|
||||||
series: body.bodyFatSeries,
|
series: body.bodyFatSeries,
|
||||||
onSave: body.setBodyFat,
|
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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,11 +4,12 @@
|
|||||||
/// drawn to scale rather than evenly spaced.
|
/// drawn to scale rather than evenly spaced.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/ui/cues.dart';
|
||||||
import 'package:diet_guard_app/ui/theme.dart';
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.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 = [
|
const List<(String, int?)> weightChartRanges = [
|
||||||
('30d', 30),
|
('30d', 30),
|
||||||
('90d', 90),
|
('90d', 90),
|
||||||
@ -61,11 +62,19 @@ class _WeightChartState extends State<WeightChart> {
|
|||||||
Wrap(
|
Wrap(
|
||||||
spacing: AppSpacing.sm,
|
spacing: AppSpacing.sm,
|
||||||
children: [
|
children: [
|
||||||
for (final (label, days) in weightChartRanges)
|
for (final (spoken, days) in weightChartRanges)
|
||||||
ChoiceChip(
|
SpokenLabel(
|
||||||
label: Text(label),
|
semanticLabel: spoken,
|
||||||
selected: _days == days,
|
child: ChoiceChip(
|
||||||
onSelected: (_) => setState(() => _days = days),
|
// 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(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: points.length < 2
|
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),
|
: _chart(context, points),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
13
app/lib/widgets/body/workout_kind_icon.dart
Normal file
13
app/lib/widgets/body/workout_kind_icon.dart
Normal 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,
|
||||||
|
};
|
||||||
@ -46,23 +46,6 @@ class DayStatusCalendar extends StatelessWidget {
|
|||||||
/// outside the displayed month is never tappable regardless.
|
/// outside the displayed month is never tappable regardless.
|
||||||
final void Function(DateTime day)? onDaySelected;
|
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) =>
|
String _dateKey(int year, int m, int day) =>
|
||||||
'$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
'$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
@ -129,13 +112,18 @@ class DayStatusCalendar extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.chevron_left, color: scheme.onSurfaceVariant),
|
icon: Icon(
|
||||||
|
Icons.chevron_left,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
semanticLabel: 'Previous month',
|
||||||
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
onPressed: onPrevMonth,
|
onPressed: onPrevMonth,
|
||||||
),
|
),
|
||||||
|
// Numeric month/year: a month name would be written chrome.
|
||||||
Text(
|
Text(
|
||||||
'${_monthNames[m - 1]} $year',
|
'${m.toString().padLeft(2, '0')} · $year',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: scheme.onSurface,
|
color: scheme.onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@ -143,7 +131,11 @@ class DayStatusCalendar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
|
icon: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
semanticLabel: 'Next month',
|
||||||
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
onPressed: onNextMonth,
|
onPressed: onNextMonth,
|
||||||
@ -153,25 +145,23 @@ class DayStatusCalendar extends StatelessWidget {
|
|||||||
const SizedBox(height: AppSpacing.xs + 2),
|
const SizedBox(height: AppSpacing.xs + 2),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: _weekHeaders
|
// Weekday names are gone: seven dots mark the Monday-first
|
||||||
.map(
|
// columns, the two weekend ones in the accent colour.
|
||||||
(h) => SizedBox(
|
children: [
|
||||||
width: 30,
|
for (var col = 0; col < 7; col++)
|
||||||
child: Text(
|
SizedBox(
|
||||||
h,
|
width: 30,
|
||||||
textAlign: TextAlign.center,
|
child: Center(
|
||||||
// Two-letter day abbreviations are UI chrome (like a
|
child: Icon(
|
||||||
// badge/tag), deliberately kept below the 16px body
|
Icons.circle,
|
||||||
// floor rather than forced to grow past their slot.
|
size: col >= 5 ? 8 : 5,
|
||||||
style: TextStyle(
|
color: col >= 5
|
||||||
color: scheme.onSurfaceVariant,
|
? scheme.primary
|
||||||
fontSize: AppTextSize.label - 2,
|
: scheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.toList(),
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
...List.generate(rows, (row) {
|
...List.generate(rows, (row) {
|
||||||
@ -218,9 +208,10 @@ class DayStatusCalendar extends StatelessWidget {
|
|||||||
// and no semantics for a screen reader. InkWell is a focus
|
// and no semantics for a screen reader. InkWell is a focus
|
||||||
// stop and is activated by Enter/Space.
|
// stop and is activated by Enter/Space.
|
||||||
final date = DateTime(year, m, day);
|
final date = DateTime(year, m, day);
|
||||||
|
final semanticLabel = 'View $day/$m/$year';
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'View $day/$m/$year',
|
label: semanticLabel,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => onDaySelected!(date),
|
onTap: () => onDaySelected!(date),
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import 'package:diet_guard_app/services/kuchnia_client.dart';
|
|||||||
import 'package:diet_guard_app/ui/theme.dart';
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Resting label, mirroring the PC gate's "🍱 Fill all".
|
/// Spoken name of the resting button (never painted).
|
||||||
const fillAllLabel = '🍱 Fill all with catering';
|
const fillAllLabel = 'Fill all with catering';
|
||||||
|
|
||||||
/// Fill-all button; relabels to "✓ Confirm (N)" once a proposal is armed.
|
/// Fill-all button; relabels to "✓ Confirm (N)" once a proposal is armed.
|
||||||
class FillAllRow extends StatelessWidget {
|
class FillAllRow extends StatelessWidget {
|
||||||
@ -52,26 +52,42 @@ class FillAllRow extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: busy ? null : onFill,
|
onPressed: busy ? null : onFill,
|
||||||
child: FittedBox(
|
child: _face(context, armed),
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(_label(armed), maxLines: 1),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (armed) ...[
|
if (armed) ...[
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
TextButton(
|
IconButton(
|
||||||
onPressed: busy ? null : onCancel,
|
onPressed: busy ? null : onCancel,
|
||||||
child: const Text('Cancel'),
|
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _label(bool armed) {
|
/// Resting glyph; spinner while busy; double check + count once armed.
|
||||||
if (busy) return 'Working…';
|
Widget _face(BuildContext context, bool armed) {
|
||||||
if (armed) return '✓ Confirm ($armedCount)';
|
if (busy) {
|
||||||
return fillAllLabel;
|
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'),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
124
app/lib/widgets/filters/filter_chrome.dart
Normal file
124
app/lib/widgets/filters/filter_chrome.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,9 +6,11 @@
|
|||||||
/// screen owns the other's controls.
|
/// screen owns the other's controls.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/ui/cues.dart';
|
||||||
|
import 'package:diet_guard_app/ui/macro_kind.dart';
|
||||||
import 'package:flutter/material.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
|
/// Stateful only to own its [TextEditingController]; the query itself lives in
|
||||||
/// the screen's [HistoryFilter].
|
/// the screen's [HistoryFilter].
|
||||||
@ -44,13 +46,11 @@ class NameSearchFieldState extends State<NameSearchField> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return TextField(
|
return IconField(
|
||||||
|
icon: Icons.search,
|
||||||
|
semanticLabel: 'Search by name',
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
decoration: const InputDecoration(
|
dense: true,
|
||||||
labelText: 'Search by name',
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: widget.onChanged,
|
onChanged: widget.onChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -112,7 +112,7 @@ class SliderSelectedLabel extends StatelessWidget {
|
|||||||
class FilterRangeRow extends StatelessWidget {
|
class FilterRangeRow extends StatelessWidget {
|
||||||
/// Creates a range row bounded by [maxValue].
|
/// Creates a range row bounded by [maxValue].
|
||||||
const FilterRangeRow({
|
const FilterRangeRow({
|
||||||
required this.label,
|
required this.kind,
|
||||||
required this.sliderKey,
|
required this.sliderKey,
|
||||||
required this.maxValue,
|
required this.maxValue,
|
||||||
required this.min,
|
required this.min,
|
||||||
@ -123,8 +123,8 @@ class FilterRangeRow extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Section heading, e.g. `Protein range (g)`.
|
/// Which nutrient this range filters; its glyph heads the row.
|
||||||
final String label;
|
final MacroKind kind;
|
||||||
|
|
||||||
/// Widget key on the slider itself, so tests can drive one row.
|
/// Widget key on the slider itself, so tests can drive one row.
|
||||||
final Key sliderKey;
|
final Key sliderKey;
|
||||||
@ -158,7 +158,14 @@ class FilterRangeRow extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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)
|
if (showEndpointLabels)
|
||||||
SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'),
|
SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/food_bank_record.dart';
|
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Prompts for a new manually-curated food bank entry.
|
/// 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),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: TextField(
|
child: IconField(
|
||||||
|
icon: kind.icon,
|
||||||
|
iconColor: kind.color,
|
||||||
|
semanticLabel: kind.semanticLabel(compact: false),
|
||||||
controller: ctrl,
|
controller: ctrl,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
decoration: InputDecoration(labelText: label, isDense: true),
|
dense: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Add to food bank'),
|
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: TextField(
|
child: IconField(
|
||||||
|
icon: Icons.restaurant,
|
||||||
|
semanticLabel: 'Name',
|
||||||
controller: _name,
|
controller: _name,
|
||||||
decoration: const InputDecoration(
|
dense: true,
|
||||||
labelText: 'Name',
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_field('Reference grams', _grams),
|
_field(MacroKind.perGrams, _grams),
|
||||||
_field('Kcal', _kcal),
|
_field(MacroKind.kcal, _kcal),
|
||||||
_field('Protein (g)', _protein),
|
_field(MacroKind.protein, _protein),
|
||||||
_field('Carbs (g)', _carbs),
|
_field(MacroKind.carbs, _carbs),
|
||||||
_field('Fat (g)', _fat),
|
_field(MacroKind.fat, _fat),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('Cancel'),
|
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _save,
|
onPressed: _save,
|
||||||
child: const Text('Save to bank'),
|
child: const Icon(Icons.check, semanticLabel: 'Save to bank'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
/// range rows come from `widgets/filters/`, shared with the history sheet.
|
/// range rows come from `widgets/filters/`, shared with the history sheet.
|
||||||
library;
|
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/filters/filter_fields.dart';
|
||||||
import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart';
|
import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -63,6 +66,35 @@ class FbFilterSheet extends StatelessWidget {
|
|||||||
/// Called when the user clears every criterion.
|
/// Called when the user clears every criterion.
|
||||||
final VoidCallback onClear;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
@ -71,35 +103,17 @@ class FbFilterSheet extends StatelessWidget {
|
|||||||
maxChildSize: 0.95,
|
maxChildSize: 0.95,
|
||||||
builder: (context, scroll) => Column(
|
builder: (context, scroll) => Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
FilterSheetHeader(onClear: onClear),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
controller: scroll,
|
controller: scroll,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
IconField(
|
||||||
decoration: const InputDecoration(
|
icon: Icons.search,
|
||||||
labelText: 'Search by name',
|
semanticLabel: 'Search by name',
|
||||||
prefixIcon: Icon(Icons.search),
|
dense: true,
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
controller: TextEditingController(text: filter.nameQuery)
|
controller: TextEditingController(text: filter.nameQuery)
|
||||||
..selection = TextSelection.collapsed(
|
..selection = TextSelection.collapsed(
|
||||||
offset: filter.nameQuery.length,
|
offset: filter.nameQuery.length,
|
||||||
@ -111,7 +125,7 @@ class FbFilterSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilterRangeRow(
|
FilterRangeRow(
|
||||||
label: 'Kcal range',
|
kind: MacroKind.kcal,
|
||||||
sliderKey: const Key('fb-kcal-range-slider'),
|
sliderKey: const Key('fb-kcal-range-slider'),
|
||||||
maxValue: maxKcal,
|
maxValue: maxKcal,
|
||||||
min: filter.minKcal,
|
min: filter.minKcal,
|
||||||
@ -124,7 +138,7 @@ class FbFilterSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
FilterRangeRow(
|
FilterRangeRow(
|
||||||
label: 'Protein range (g)',
|
kind: MacroKind.protein,
|
||||||
sliderKey: const Key('fb-protein-range-slider'),
|
sliderKey: const Key('fb-protein-range-slider'),
|
||||||
maxValue: maxProtein,
|
maxValue: maxProtein,
|
||||||
min: filter.minProtein,
|
min: filter.minProtein,
|
||||||
@ -138,7 +152,7 @@ class FbFilterSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
FilterRangeRow(
|
FilterRangeRow(
|
||||||
label: 'Carbs range (g)',
|
kind: MacroKind.carbs,
|
||||||
sliderKey: const Key('fb-carbs-range-slider'),
|
sliderKey: const Key('fb-carbs-range-slider'),
|
||||||
maxValue: maxCarbs,
|
maxValue: maxCarbs,
|
||||||
min: filter.minCarbs,
|
min: filter.minCarbs,
|
||||||
@ -152,7 +166,7 @@ class FbFilterSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
FilterRangeRow(
|
FilterRangeRow(
|
||||||
label: 'Fat range (g)',
|
kind: MacroKind.fat,
|
||||||
sliderKey: const Key('fb-fat-range-slider'),
|
sliderKey: const Key('fb-fat-range-slider'),
|
||||||
maxValue: maxFat,
|
maxValue: maxFat,
|
||||||
min: filter.minFat,
|
min: filter.minFat,
|
||||||
@ -165,74 +179,16 @@ class FbFilterSheet extends StatelessWidget {
|
|||||||
onFilterChanged(filter);
|
onFilterChanged(filter);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Text(
|
SortChoiceRow<FbSortField>(
|
||||||
'Sort by',
|
options: _sortOptions,
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
selected: sortField,
|
||||||
),
|
ascending: ascending,
|
||||||
const SizedBox(height: 4),
|
onChanged: onSortChanged,
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
FilterApplyBar(onApply: onApply),
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: onApply,
|
|
||||||
child: const Text('Apply'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,9 +2,13 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/food_bank_record.dart';
|
import 'package:diet_guard_app/models/food_bank_record.dart';
|
||||||
|
import 'package:diet_guard_app/ui/macro_kind.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// One banked food: its name, macros, and how often it has been logged.
|
/// 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 {
|
class RecordTile extends StatelessWidget {
|
||||||
/// Creates a [RecordTile] for [record].
|
/// Creates a [RecordTile] for [record].
|
||||||
const RecordTile(this.record, {super.key});
|
const RecordTile(this.record, {super.key});
|
||||||
@ -14,19 +18,23 @@ class RecordTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final macros =
|
String n(double v) => v.toStringAsFixed(0);
|
||||||
'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'
|
|
||||||
: '';
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(record.desc),
|
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
|
trailing: record.count > 0
|
||||||
? Text(
|
? Text(
|
||||||
'×${record.count.toStringAsFixed(0)}',
|
'×${n(record.count)}',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/food_bank_record.dart';
|
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:diet_guard_app/ui/theme.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@ -59,11 +60,13 @@ class FbTile extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Align(
|
||||||
'${record.kcal.toStringAsFixed(0)} kcal',
|
alignment: Alignment.centerLeft,
|
||||||
maxLines: 1,
|
child: MacroFigure(
|
||||||
overflow: TextOverflow.ellipsis,
|
kind: MacroKind.kcal,
|
||||||
style: theme.textTheme.bodySmall,
|
value: record.kcal.toStringAsFixed(0),
|
||||||
|
size: 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (record.count > 0)
|
if (record.count > 0)
|
||||||
|
|||||||
@ -3,10 +3,13 @@
|
|||||||
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
|
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
|
||||||
library;
|
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/filters/filter_fields.dart';
|
||||||
import 'package:diet_guard_app/widgets/history/history_filter.dart';
|
import 'package:diet_guard_app/widgets/history/history_filter.dart';
|
||||||
import 'package:diet_guard_app/widgets/history/history_filter_sort.dart';
|
import 'package:diet_guard_app/widgets/history/history_filter_sort.dart';
|
||||||
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
|
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// The draggable bottom sheet that edits the history filter and sort order.
|
/// The draggable bottom sheet that edits the history filter and sort order.
|
||||||
@ -73,20 +76,7 @@ class FilterSheet extends StatelessWidget {
|
|||||||
maxChildSize: 0.95,
|
maxChildSize: 0.95,
|
||||||
builder: (context, scroll) => Column(
|
builder: (context, scroll) => Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
FilterSheetHeader(onClear: onClear),
|
||||||
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')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@ -105,21 +95,24 @@ class FilterSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Date range
|
// Date range: the glyph alone means "any date"; once a range
|
||||||
Text(
|
// is set its dates (data, not chrome) appear beside it.
|
||||||
'Date range',
|
OutlinedButton(
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
child: Row(
|
||||||
),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
const SizedBox(height: 4),
|
children: [
|
||||||
OutlinedButton.icon(
|
const Icon(
|
||||||
icon: const Icon(Icons.date_range),
|
Icons.date_range,
|
||||||
label: Text(
|
semanticLabel: 'Date range',
|
||||||
filter.dateRange == null
|
),
|
||||||
? 'Any date'
|
if (filter.dateRange != null) ...[
|
||||||
: dateRangeLabel(filter.dateRange!),
|
const SizedBox(width: 8),
|
||||||
|
Text(dateRangeLabel(filter.dateRange!)),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final picked = await showDateRangePicker(
|
final picked = await pickDateRange(
|
||||||
context: context,
|
context: context,
|
||||||
firstDate: DateTime(2020),
|
firstDate: DateTime(2020),
|
||||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||||
@ -132,17 +125,20 @@ class FilterSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (filter.dateRange != null)
|
if (filter.dateRange != null)
|
||||||
TextButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
filter.dateRange = null;
|
filter.dateRange = null;
|
||||||
onFilterChanged(filter);
|
onFilterChanged(filter);
|
||||||
},
|
},
|
||||||
child: const Text('Clear date range'),
|
icon: const Icon(
|
||||||
|
Icons.event_busy,
|
||||||
|
semanticLabel: 'Clear date range',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
FilterRangeRow(
|
FilterRangeRow(
|
||||||
label: 'Kcal range',
|
kind: MacroKind.kcal,
|
||||||
sliderKey: const Key('kcal-range-slider'),
|
sliderKey: const Key('kcal-range-slider'),
|
||||||
maxValue: maxKcal,
|
maxValue: maxKcal,
|
||||||
min: filter.minKcal,
|
min: filter.minKcal,
|
||||||
@ -154,7 +150,7 @@ class FilterSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
FilterRangeRow(
|
FilterRangeRow(
|
||||||
label: 'Protein range (g)',
|
kind: MacroKind.protein,
|
||||||
sliderKey: const Key('protein-range-slider'),
|
sliderKey: const Key('protein-range-slider'),
|
||||||
maxValue: maxProtein,
|
maxValue: maxProtein,
|
||||||
min: filter.minProtein,
|
min: filter.minProtein,
|
||||||
@ -167,7 +163,7 @@ class FilterSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
FilterRangeRow(
|
FilterRangeRow(
|
||||||
label: 'Carbs range (g)',
|
kind: MacroKind.carbs,
|
||||||
sliderKey: const Key('carbs-range-slider'),
|
sliderKey: const Key('carbs-range-slider'),
|
||||||
maxValue: maxCarbs,
|
maxValue: maxCarbs,
|
||||||
min: filter.minCarbs,
|
min: filter.minCarbs,
|
||||||
@ -180,7 +176,7 @@ class FilterSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
FilterRangeRow(
|
FilterRangeRow(
|
||||||
label: 'Fat range (g)',
|
kind: MacroKind.fat,
|
||||||
sliderKey: const Key('fat-range-slider'),
|
sliderKey: const Key('fat-range-slider'),
|
||||||
maxValue: maxFat,
|
maxValue: maxFat,
|
||||||
min: filter.minFat,
|
min: filter.minFat,
|
||||||
@ -210,16 +206,7 @@ class FilterSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
FilterApplyBar(onApply: onApply),
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: onApply,
|
|
||||||
child: const Text('Apply'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,8 +3,12 @@
|
|||||||
/// Split from `history_filter_sheet.dart` for the repo's 250-line cap. Both are
|
/// 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
|
/// 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.
|
/// 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;
|
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:diet_guard_app/widgets/history/history_filter.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@ -15,7 +19,7 @@ import 'package:flutter/material.dart';
|
|||||||
/// a constraint on what `source` may contain.
|
/// a constraint on what `source` may contain.
|
||||||
const _sources = ['manual', 'food bank'];
|
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 {
|
class FilterSourceChips extends StatelessWidget {
|
||||||
/// Creates source chips with [selected] currently active.
|
/// Creates source chips with [selected] currently active.
|
||||||
const FilterSourceChips({
|
const FilterSourceChips({
|
||||||
@ -24,41 +28,38 @@ class FilterSourceChips extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The active source, or null for "All".
|
/// The active source, or null for "all".
|
||||||
final String? selected;
|
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;
|
final ValueChanged<String?> onSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Wrap(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Text('Source', style: Theme.of(context).textTheme.labelLarge),
|
FilterChip(
|
||||||
const SizedBox(height: 4),
|
label: const Icon(
|
||||||
Wrap(
|
Icons.all_inclusive,
|
||||||
spacing: 8,
|
size: 18,
|
||||||
children: [
|
semanticLabel: 'All',
|
||||||
FilterChip(
|
),
|
||||||
label: const Text('All'),
|
selected: selected == null,
|
||||||
selected: selected == null,
|
onSelected: (_) => onSelected(null),
|
||||||
onSelected: (_) => onSelected(null),
|
|
||||||
),
|
|
||||||
for (final src in _sources)
|
|
||||||
FilterChip(
|
|
||||||
label: Text(src),
|
|
||||||
selected: selected == src,
|
|
||||||
onSelected: (_) => onSelected(src),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
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 {
|
class FilterSortControls extends StatelessWidget {
|
||||||
/// Creates sort controls reflecting [sortField] and [ascending].
|
/// Creates sort controls reflecting [sortField] and [ascending].
|
||||||
const FilterSortControls({
|
const FilterSortControls({
|
||||||
@ -78,63 +79,44 @@ class FilterSortControls extends StatelessWidget {
|
|||||||
final void Function({required HistorySortField field, required bool asc})
|
final void Function({required HistorySortField field, required bool asc})
|
||||||
onSortChanged;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => SortChoiceRow<HistorySortField>(
|
||||||
return Column(
|
options: _options,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
selected: sortField,
|
||||||
children: [
|
ascending: ascending,
|
||||||
Text('Sort by', style: Theme.of(context).textTheme.labelLarge),
|
onChanged: onSortChanged,
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,29 +170,17 @@ String dateRangeLabel(DateTimeRange r) =>
|
|||||||
'${r.start.toString().substring(0, 10)}'
|
'${r.start.toString().substring(0, 10)}'
|
||||||
' – ${r.end.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
|
/// Falls back to the raw key when it cannot be parsed, so a malformed date
|
||||||
/// shows something rather than throwing inside a list build.
|
/// shows something rather than throwing inside a list build.
|
||||||
String formatDay(String dateKey) {
|
String formatDay(String dateKey) {
|
||||||
try {
|
try {
|
||||||
final d = DateTime.parse(dateKey);
|
final d = DateTime.parse(dateKey);
|
||||||
const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
final day = d.day.toString().padLeft(2, '0');
|
||||||
const mo = [
|
final month = d.month.toString().padLeft(2, '0');
|
||||||
'Jan',
|
return '$day.$month.${d.year}';
|
||||||
'Feb',
|
|
||||||
'Mar',
|
|
||||||
'Apr',
|
|
||||||
'May',
|
|
||||||
'Jun',
|
|
||||||
'Jul',
|
|
||||||
'Aug',
|
|
||||||
'Sep',
|
|
||||||
'Oct',
|
|
||||||
'Nov',
|
|
||||||
'Dec',
|
|
||||||
];
|
|
||||||
return '${wd[d.weekday - 1]} ${d.day} ${mo[d.month - 1]} ${d.year}';
|
|
||||||
} on Exception {
|
} on Exception {
|
||||||
return dateKey;
|
return dateKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,14 @@
|
|||||||
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
|
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/food_entry.dart';
|
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/budget_history_service.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_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:diet_guard_app/widgets/history/history_grouping.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@ -84,28 +89,44 @@ class DayHeaderTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Icon(
|
||||||
'${header.entryCount}'
|
Icons.restaurant,
|
||||||
' ${header.entryCount == 1 ? 'entry' : 'entries'}',
|
size: 14,
|
||||||
style: textTheme.bodySmall,
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
semanticLabel: 'entries',
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text('${header.entryCount}', style: textTheme.bodySmall),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
Icon(
|
||||||
|
MacroKind.kcal.icon,
|
||||||
|
size: 14,
|
||||||
|
color: kcalColor,
|
||||||
|
semanticLabel: 'kcal',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
'${header.totalKcal.round()} / $goal kcal',
|
'${header.totalKcal.round()} / $goal',
|
||||||
style: textTheme.bodySmall?.copyWith(color: kcalColor),
|
style: textTheme.bodySmall?.copyWith(color: kcalColor),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
MacroFigure(
|
||||||
|
kind: MacroKind.protein,
|
||||||
|
value: '${header.totalProtein.round()}g',
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
MacroFigure(
|
||||||
'P ${header.totalProtein.round()}g · '
|
kind: MacroKind.carbs,
|
||||||
'C ${header.totalCarbs.round()}g · '
|
value: '${header.totalCarbs.round()}g',
|
||||||
'F ${header.totalFat.round()}g',
|
),
|
||||||
style: textTheme.bodySmall?.copyWith(
|
const SizedBox(width: 8),
|
||||||
color: colorScheme.onSurfaceVariant,
|
MacroFigure(
|
||||||
),
|
kind: MacroKind.fat,
|
||||||
|
value: '${header.totalFat.round()}g',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -135,7 +156,13 @@ class EntryTile extends StatelessWidget {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.restaurant),
|
leading: const Icon(Icons.restaurant),
|
||||||
title: Text(entry.desc),
|
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
|
// 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
|
// no keyboard equivalent at all, so on the desktop build (a Chrome --app
|
||||||
// window) deleting an entry was pointer-only: unreachable by keyboard and
|
// window) deleting an entry was pointer-only: unreachable by keyboard and
|
||||||
@ -144,12 +171,18 @@ class EntryTile extends StatelessWidget {
|
|||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('${entry.kcal.toStringAsFixed(0)} kcal'),
|
MacroFigure(
|
||||||
|
kind: MacroKind.kcal,
|
||||||
|
value: entry.kcal.toStringAsFixed(0),
|
||||||
|
size: 14,
|
||||||
|
),
|
||||||
if (canDelete)
|
if (canDelete)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(
|
||||||
tooltip: 'Delete entry',
|
Icons.delete_outline,
|
||||||
onPressed: () => _confirmDelete(context),
|
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.
|
// Delete remains id-only to avoid ambiguous time+desc matches.
|
||||||
onTap: () => onEdit?.call(),
|
onTap: () => onEdit?.call(),
|
||||||
// Retained as a redundant pointer shortcut, never the only route.
|
// 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 {
|
/// Deletes at once and offers an icon-only undo instead of asking first.
|
||||||
final confirmed = await showDialog<bool>(
|
///
|
||||||
context: context,
|
/// The snackbar is raised before the awaits because the reload that
|
||||||
builder: (_) => AlertDialog(
|
/// follows rebuilds the list and may dispose this tile's context. Undo
|
||||||
title: const Text('Delete entry?'),
|
/// writes the live copy back over the tombstone (same id), then reloads.
|
||||||
content: Text('Remove "${entry.desc}" from history?'),
|
Future<void> _delete(BuildContext context) async {
|
||||||
actions: [
|
showCueSnack(
|
||||||
TextButton(
|
context,
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
kind: CueKind.ok,
|
||||||
child: const Text('Cancel'),
|
icon: Icons.delete_outline,
|
||||||
),
|
semanticLabel: 'Entry deleted',
|
||||||
TextButton(
|
onUndo: () => unawaited(_restore()),
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
|
||||||
child: const Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (confirmed == true) {
|
await LogStorageService.instance.deleteEntry(entry.id!);
|
||||||
await LogStorageService.instance.deleteEntry(entry.id!);
|
await onDelete?.call();
|
||||||
await onDelete?.call();
|
}
|
||||||
}
|
|
||||||
|
Future<void> _restore() async {
|
||||||
|
await LogStorageService.instance.updateEntry(
|
||||||
|
entry.copyWithDeleted(),
|
||||||
|
entry,
|
||||||
|
);
|
||||||
|
await onDelete?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,44 +52,44 @@ class LogMealActionsRow extends StatelessWidget {
|
|||||||
final canFetch = canFetchDelivery ?? kuchniaFetchSupported;
|
final canFetch = canFetchDelivery ?? kuchniaFetchSupported;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Flexible, not Spacer-and-hope: `OutlinedButton.icon` lays its label
|
// Expanded so the delivery button takes the slack and pushes the log
|
||||||
// out unbounded, so on a narrow phone the queue suffix renders wider
|
// button right; the queue is a numeral next to the icon, so there is
|
||||||
// than the row and is clipped without ever throwing an overflow. The
|
// no wide label to clip. While fetching the icon becomes a spinner.
|
||||||
// Flexible caps the width and the FittedBox scales the text into it.
|
|
||||||
if (canFetch)
|
if (canFetch)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Tooltip(
|
child: OutlinedButton(
|
||||||
message: "Load today's catering delivery",
|
onPressed: deliveryBusy ? null : onLoadDelivery,
|
||||||
child: OutlinedButton.icon(
|
child: Row(
|
||||||
onPressed: deliveryBusy ? null : onLoadDelivery,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
icon: const Icon(Icons.restaurant),
|
children: [
|
||||||
label: FittedBox(
|
if (deliveryBusy)
|
||||||
fit: BoxFit.scaleDown,
|
const SizedBox(
|
||||||
child: Text(_label, maxLines: 1),
|
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
|
// No Spacer when the delivery button is present: it is a flex child
|
||||||
// with the Flexible above and re-create the clipping this guards
|
// too and would split the free space with the Expanded above.
|
||||||
// against. The Flexible takes the slack and pushes the log button
|
|
||||||
// right on its own.
|
|
||||||
if (!canFetch) const Spacer(),
|
if (!canFetch) const Spacer(),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Tooltip(
|
FilledButton(
|
||||||
message: 'Log meal',
|
onPressed: onLog,
|
||||||
child: FilledButton(
|
child: const Icon(Icons.check_circle, semanticLabel: 'Log meal'),
|
||||||
onPressed: onLog,
|
|
||||||
child: const Icon(Icons.check_circle),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _label {
|
|
||||||
if (deliveryBusy) return 'Loading…';
|
|
||||||
if (dishesQueued > 0) return "Today's delivery ($dishesQueued more to go)";
|
|
||||||
return "Today's delivery";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/nutrition.dart';
|
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Text controllers for one macro-entry row, owned by the calling screen so
|
/// 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.
|
/// optional reference-weight-vs-eaten-weight split.
|
||||||
///
|
///
|
||||||
/// Layout mirrors the Python gate's macro section: the reference weight
|
/// Layout mirrors the Python gate's macro section: the reference weight
|
||||||
@ -116,76 +119,59 @@ class MacroInputRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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) {
|
if (compact) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _macroField('per', controllers.perGrams)),
|
for (final (i, field) in fields.indexed) ...[
|
||||||
const SizedBox(width: 4),
|
if (i > 0) const SizedBox(width: 4),
|
||||||
Expanded(child: _macroField('kcal', controllers.kcal)),
|
Expanded(child: field),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// per-gram reference weight and kcal on the same line.
|
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(width: 96, child: fields[0]),
|
||||||
width: 72,
|
|
||||||
child: _macroField('per (g)', controllers.perGrams),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(child: _macroField('kcal', controllers.kcal)),
|
Expanded(child: fields[1]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _macroField('protein g', controllers.protein)),
|
for (final (i, field) in fields.sublist(2, 5).indexed) ...[
|
||||||
const SizedBox(width: 8),
|
if (i > 0) const SizedBox(width: 8),
|
||||||
Expanded(child: _macroField('carbs g', controllers.carbs)),
|
Expanded(child: field),
|
||||||
const SizedBox(width: 8),
|
],
|
||||||
Expanded(child: _macroField('fat g', controllers.fat)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_macroField(
|
fields[5],
|
||||||
'eaten (g)',
|
|
||||||
controllers.grams,
|
|
||||||
helperText: "blank = same as 'per (g)'",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _macroField(
|
Widget _macroField(MacroKind kind, TextEditingController controller) {
|
||||||
String label,
|
return IconField(
|
||||||
TextEditingController controller, {
|
icon: kind.icon,
|
||||||
String? helperText,
|
iconColor: kind.color,
|
||||||
}) {
|
semanticLabel: kind.semanticLabel(compact: compact),
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
controller: controller,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
decoration: InputDecoration(
|
dense: true,
|
||||||
labelText: label,
|
|
||||||
helperText: helperText,
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ library;
|
|||||||
|
|
||||||
import 'package:diet_guard_app/models/meal_schedule.dart';
|
import 'package:diet_guard_app/models/meal_schedule.dart';
|
||||||
import 'package:diet_guard_app/models/slot.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/ui/theme.dart';
|
||||||
import 'package:diet_guard_app/widgets/meal_time_dropdown.dart';
|
import 'package:diet_guard_app/widgets/meal_time_dropdown.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -61,15 +62,9 @@ class MealScheduleEditor extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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(
|
MealTimeDropdown(
|
||||||
label: 'First meal',
|
icon: Icons.light_mode_outlined,
|
||||||
|
semanticLabel: 'First meal',
|
||||||
value: schedule.firstMinute,
|
value: schedule.firstMinute,
|
||||||
minMinute: 0,
|
minMinute: 0,
|
||||||
maxMinute: _firstMax,
|
maxMinute: _firstMax,
|
||||||
@ -78,7 +73,8 @@ class MealScheduleEditor extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
MealTimeDropdown(
|
MealTimeDropdown(
|
||||||
label: 'Last meal',
|
icon: Icons.dark_mode_outlined,
|
||||||
|
semanticLabel: 'Last meal',
|
||||||
value: schedule.lastMinute,
|
value: schedule.lastMinute,
|
||||||
// At least one grid step after the first meal: the shortest window
|
// At least one grid step after the first meal: the shortest window
|
||||||
// that still holds two distinct checkpoints.
|
// that still holds two distinct checkpoints.
|
||||||
@ -89,21 +85,26 @@ class MealScheduleEditor extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
||||||
child: InputDecorator(
|
child: SpokenLabel(
|
||||||
decoration: const InputDecoration(labelText: 'Meals per day'),
|
semanticLabel: 'Meals per day',
|
||||||
child: DropdownButtonHideUnderline(
|
child: InputDecorator(
|
||||||
child: DropdownButton<int>(
|
decoration: const InputDecoration(
|
||||||
value: counts.contains(schedule.count)
|
prefixIcon: ExcludeSemantics(child: Icon(Icons.restaurant)),
|
||||||
? schedule.count
|
),
|
||||||
: counts.last,
|
child: DropdownButtonHideUnderline(
|
||||||
isDense: true,
|
child: DropdownButton<int>(
|
||||||
isExpanded: true,
|
value: counts.contains(schedule.count)
|
||||||
items: [
|
? schedule.count
|
||||||
for (final count in counts)
|
: counts.last,
|
||||||
DropdownMenuItem(value: count, child: Text('$count')),
|
isDense: true,
|
||||||
],
|
isExpanded: true,
|
||||||
onChanged: (count) =>
|
items: [
|
||||||
count == null ? null : onChanged(_with(count: count)),
|
for (final count in counts)
|
||||||
|
DropdownMenuItem(value: count, child: Text('$count')),
|
||||||
|
],
|
||||||
|
onChanged: (count) =>
|
||||||
|
count == null ? null : onChanged(_with(count: count)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import 'dart:async';
|
|||||||
import 'package:diet_guard_app/models/meal_schedule.dart';
|
import 'package:diet_guard_app/models/meal_schedule.dart';
|
||||||
import 'package:diet_guard_app/models/slot.dart';
|
import 'package:diet_guard_app/models/slot.dart';
|
||||||
import 'package:diet_guard_app/ui/theme.dart';
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/pickers.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Picks a minute of day, offering the 15-minute grid within
|
/// Picks a minute of day, offering the 15-minute grid within
|
||||||
@ -26,7 +27,8 @@ import 'package:flutter/material.dart';
|
|||||||
class MealTimeDropdown extends StatelessWidget {
|
class MealTimeDropdown extends StatelessWidget {
|
||||||
/// Creates a [MealTimeDropdown].
|
/// Creates a [MealTimeDropdown].
|
||||||
const MealTimeDropdown({
|
const MealTimeDropdown({
|
||||||
required this.label,
|
required this.icon,
|
||||||
|
required this.semanticLabel,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.minMinute,
|
required this.minMinute,
|
||||||
required this.maxMinute,
|
required this.maxMinute,
|
||||||
@ -35,8 +37,11 @@ class MealTimeDropdown extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The field label, e.g. "First meal".
|
/// The painted caption glyph, e.g. a sun for the first meal.
|
||||||
final String label;
|
final IconData icon;
|
||||||
|
|
||||||
|
/// The spoken caption, e.g. "First meal"; never painted.
|
||||||
|
final String semanticLabel;
|
||||||
|
|
||||||
/// The current minute of day.
|
/// The current minute of day.
|
||||||
final int value;
|
final int value;
|
||||||
@ -52,8 +57,8 @@ class MealTimeDropdown extends StatelessWidget {
|
|||||||
/// while the clock button may go to 23:44). Defaults to [maxMinute].
|
/// while the clock button may go to 23:44). Defaults to [maxMinute].
|
||||||
final int? gridMaxMinute;
|
final int? gridMaxMinute;
|
||||||
|
|
||||||
/// Called with the chosen minute, already clamped into
|
/// Called with the chosen minute, always within [minMinute]..[maxMinute]
|
||||||
/// [minMinute]..[maxMinute]. Not called when the picker is dismissed.
|
/// (the time picker cannot pick outside it). Not called when dismissed.
|
||||||
final ValueChanged<int> onChanged;
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
/// The minutes listed, ascending: the grid marks in range plus [value].
|
/// The minutes listed, ascending: the grid marks in range plus [value].
|
||||||
@ -73,27 +78,15 @@ class MealTimeDropdown extends StatelessWidget {
|
|||||||
}.toList()..sort();
|
}.toList()..sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
int _clampToRange(int minute) => minute < minMinute
|
|
||||||
? minMinute
|
|
||||||
: (minute > maxMinute ? maxMinute : minute);
|
|
||||||
|
|
||||||
Future<void> _pickExact(BuildContext context) async {
|
Future<void> _pickExact(BuildContext context) async {
|
||||||
final picked = await showTimePicker(
|
final picked = await pickTime(
|
||||||
context: context,
|
context: context,
|
||||||
initialTime: TimeOfDay(hour: value ~/ 60, minute: value % 60),
|
initialTime: TimeOfDay(hour: value ~/ 60, minute: value % 60),
|
||||||
helpText: label,
|
minMinute: minMinute,
|
||||||
// The rest of the app writes times as HH:MM; an AM/PM dial here would
|
maxMinute: maxMinute,
|
||||||
// be the one place that reads differently.
|
|
||||||
builder: (context, child) => MediaQuery(
|
|
||||||
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
|
|
||||||
child: child!,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (picked == null) return;
|
if (picked == null) return;
|
||||||
// Clamped rather than rejected: the picker cannot be told the range, and
|
onChanged(picked.hour * 60 + picked.minute);
|
||||||
// the nearest legal minute is what `MealSchedule.normalized` would
|
|
||||||
// produce anyway -- doing it here keeps the dropdown's own contract.
|
|
||||||
onChanged(_clampToRange(picked.hour * 60 + picked.minute));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -103,29 +96,37 @@ class MealTimeDropdown extends StatelessWidget {
|
|||||||
spacing: AppSpacing.xs,
|
spacing: AppSpacing.xs,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InputDecorator(
|
child: Semantics(
|
||||||
decoration: InputDecoration(labelText: label),
|
label: semanticLabel,
|
||||||
child: DropdownButtonHideUnderline(
|
container: true,
|
||||||
child: DropdownButton<int>(
|
child: InputDecorator(
|
||||||
value: value,
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
prefixIcon: ExcludeSemantics(child: Icon(icon)),
|
||||||
isExpanded: true,
|
),
|
||||||
items: [
|
child: DropdownButtonHideUnderline(
|
||||||
for (final minute in options)
|
child: DropdownButton<int>(
|
||||||
DropdownMenuItem(
|
value: value,
|
||||||
value: minute,
|
isDense: true,
|
||||||
child: Text(slotLabel(minute)),
|
isExpanded: true,
|
||||||
),
|
items: [
|
||||||
],
|
for (final minute in options)
|
||||||
onChanged: (minute) =>
|
DropdownMenuItem(
|
||||||
minute == null ? null : onChanged(minute),
|
value: minute,
|
||||||
|
child: Text(slotLabel(minute)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (minute) =>
|
||||||
|
minute == null ? null : onChanged(minute),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.schedule),
|
icon: const Icon(
|
||||||
tooltip: 'Type or pick an exact time',
|
Icons.schedule,
|
||||||
|
semanticLabel: 'Type or pick an exact time',
|
||||||
|
),
|
||||||
onPressed: () => unawaited(_pickExact(context)),
|
onPressed: () => unawaited(_pickExact(context)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
127
app/lib/widgets/pickers/date_dialog.dart
Normal file
127
app/lib/widgets/pickers/date_dialog.dart
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/// The date and date-range dialog: month chips above, the day grid with the
|
||||||
|
/// year wheel beside it, check and close icons below.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_grid.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/month_chips.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/picker_frame.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/year_wheel.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Width of the year wheel beside the grid.
|
||||||
|
const double kYearWheelWidth = 64;
|
||||||
|
|
||||||
|
/// Pops a `DateTime` (single mode) or a `DateTimeRange` (range mode).
|
||||||
|
///
|
||||||
|
/// Keyboard: arrows move the grid cursor by a day or a week, Space taps the
|
||||||
|
/// cursor day in range mode, Enter confirms, Esc cancels.
|
||||||
|
class DateDialog extends StatefulWidget {
|
||||||
|
/// Creates a [DateDialog] over [selection].
|
||||||
|
const DateDialog({
|
||||||
|
required this.selection,
|
||||||
|
this.startOnYear = false,
|
||||||
|
this.today,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The selection to edit; its mode decides the popped type.
|
||||||
|
final DateSelection selection;
|
||||||
|
|
||||||
|
/// Focus the year wheel first (the birth-date flow) instead of the grid.
|
||||||
|
final bool startOnYear;
|
||||||
|
|
||||||
|
/// The day to ring; now when null (a seam for tests).
|
||||||
|
final DateTime? today;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DateDialog> createState() => _DateDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateDialogState extends State<DateDialog> {
|
||||||
|
DateSelection get _sel => widget.selection;
|
||||||
|
bool _gridFocused = false;
|
||||||
|
|
||||||
|
void _confirm() => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop<Object>(_sel.range ? _sel.dateRange : _sel.start);
|
||||||
|
|
||||||
|
KeyEventResult _key(FocusNode _, KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
final step = switch (event.logicalKey) {
|
||||||
|
LogicalKeyboardKey.arrowLeft => -1,
|
||||||
|
LogicalKeyboardKey.arrowRight => 1,
|
||||||
|
LogicalKeyboardKey.arrowUp => -7,
|
||||||
|
LogicalKeyboardKey.arrowDown => 7,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if (step != 0) {
|
||||||
|
setState(() => _sel.moveCursor(step));
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (_sel.range && event.logicalKey == LogicalKeyboardKey.space) {
|
||||||
|
setState(() => _sel.tap(_sel.cursor));
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => PickerFrame(
|
||||||
|
canConfirm: _sel.canConfirm,
|
||||||
|
onConfirm: _confirm,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
MonthChips(
|
||||||
|
year: _sel.year,
|
||||||
|
selected: _sel.month,
|
||||||
|
allowed: (m) => _sel.monthAllowed(_sel.year, m),
|
||||||
|
onTap: (m) => setState(() => _sel.showMonth(m)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final gridWidth =
|
||||||
|
constraints.maxWidth - kYearWheelWidth - AppSpacing.sm;
|
||||||
|
return SizedBox(
|
||||||
|
height: gridWidth / 7 * (kGridCells ~/ 7),
|
||||||
|
child: Row(
|
||||||
|
spacing: AppSpacing.sm,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Focus(
|
||||||
|
autofocus: !widget.startOnYear,
|
||||||
|
onKeyEvent: _key,
|
||||||
|
onFocusChange: (f) => setState(() => _gridFocused = f),
|
||||||
|
child: DateGrid(
|
||||||
|
selection: _sel,
|
||||||
|
today: widget.today ?? DateTime.now(),
|
||||||
|
showCursor: _gridFocused,
|
||||||
|
onTap: (day) => setState(() => _sel.tap(day)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: kYearWheelWidth,
|
||||||
|
child: YearWheel(
|
||||||
|
firstYear: _sel.first.year,
|
||||||
|
lastYear: _sel.last.year,
|
||||||
|
value: _sel.year,
|
||||||
|
autofocus: widget.startOnYear,
|
||||||
|
onChanged: (y) => setState(() => _sel.showYear(y)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
74
app/lib/widgets/pickers/date_grid.dart
Normal file
74
app/lib/widgets/pickers/date_grid.dart
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/// The day grid: one month of numerals, Monday first, with no weekday header.
|
||||||
|
///
|
||||||
|
/// One widget serves the single-date and the range picker; the mode lives in
|
||||||
|
/// [DateSelection], so the range picker is a mode of this grid, not a copy.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/numeral_cell.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Six rows of seven: every month fits, and the dialog never changes height.
|
||||||
|
const int kGridCells = 42;
|
||||||
|
|
||||||
|
/// The shown month of [selection] as a numeral grid.
|
||||||
|
///
|
||||||
|
/// Today has a ring; weekends are dimmed with a dot; out-of-range days are
|
||||||
|
/// dimmed further and inert. The chosen day, or both range ends, are filled;
|
||||||
|
/// the days of a range are shaded.
|
||||||
|
class DateGrid extends StatelessWidget {
|
||||||
|
/// Creates a [DateGrid].
|
||||||
|
const DateGrid({
|
||||||
|
required this.selection,
|
||||||
|
required this.today,
|
||||||
|
required this.onTap,
|
||||||
|
this.showCursor = false,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The selection being edited (read only here).
|
||||||
|
final DateSelection selection;
|
||||||
|
|
||||||
|
/// Today's date, ringed.
|
||||||
|
final DateTime today;
|
||||||
|
|
||||||
|
/// Called with a tapped in-range day.
|
||||||
|
final ValueChanged<DateTime> onTap;
|
||||||
|
|
||||||
|
/// Whether to outline the keyboard cursor (the grid has focus).
|
||||||
|
final bool showCursor;
|
||||||
|
|
||||||
|
bool _inBand(DateTime day) {
|
||||||
|
final start = selection.start;
|
||||||
|
final end = selection.end;
|
||||||
|
if (start == null || end == null || start == end) return false;
|
||||||
|
return !day.isBefore(start) && !day.isAfter(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = MaterialLocalizations.of(context);
|
||||||
|
final year = selection.year;
|
||||||
|
final month = selection.month;
|
||||||
|
final lead = DateTime(year, month).weekday - DateTime.monday;
|
||||||
|
final days = DateUtils.getDaysInMonth(year, month);
|
||||||
|
return numeralRows(7, [
|
||||||
|
for (var i = 0; i < lead; i++) const SizedBox.shrink(),
|
||||||
|
for (var d = 1; d <= days; d++) _cell(DateTime(year, month, d), l10n),
|
||||||
|
for (var i = lead + days; i < kGridCells; i++) const SizedBox.shrink(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cell(DateTime day, MaterialLocalizations l10n) => NumeralCell(
|
||||||
|
key: ValueKey(day),
|
||||||
|
label: '${day.day}',
|
||||||
|
semanticLabel: l10n.formatFullDate(day),
|
||||||
|
onTap: () => onTap(day),
|
||||||
|
enabled: selection.allows(day),
|
||||||
|
selected: day == selection.start || day == selection.end,
|
||||||
|
band: _inBand(day),
|
||||||
|
ring: DateUtils.isSameDay(day, today),
|
||||||
|
dot: day.weekday >= DateTime.saturday,
|
||||||
|
focused: showCursor && day == selection.cursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
140
app/lib/widgets/pickers/date_selection.dart
Normal file
140
app/lib/widgets/pickers/date_selection.dart
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/// The date picker's selection rules, kept free of widgets so each rule is a
|
||||||
|
/// plain unit test: bounds clamp, the two-tap range, the month/year day clamp.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart' show DateTimeRange, DateUtils;
|
||||||
|
|
||||||
|
/// What the date picker has chosen, and which month it is showing.
|
||||||
|
///
|
||||||
|
/// Every date is date-only (midnight local); days are stepped with
|
||||||
|
/// `DateTime(y, m, d + n)`, never a `Duration`, so DST cannot shift a day.
|
||||||
|
class DateSelection {
|
||||||
|
/// A single-date selection starting on [initial], clamped into bounds.
|
||||||
|
DateSelection.single({
|
||||||
|
required DateTime first,
|
||||||
|
required DateTime last,
|
||||||
|
DateTime? initial,
|
||||||
|
}) : first = DateUtils.dateOnly(first),
|
||||||
|
last = DateUtils.dateOnly(last),
|
||||||
|
range = false {
|
||||||
|
start = _clamp(initial ?? DateTime.now());
|
||||||
|
_init(start!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A two-tap range selection. A preset [initial] range counts as both taps
|
||||||
|
/// done, so it can be confirmed at once.
|
||||||
|
DateSelection.range({
|
||||||
|
required DateTime first,
|
||||||
|
required DateTime last,
|
||||||
|
DateTimeRange? initial,
|
||||||
|
}) : first = DateUtils.dateOnly(first),
|
||||||
|
last = DateUtils.dateOnly(last),
|
||||||
|
range = true {
|
||||||
|
if (initial != null) {
|
||||||
|
start = _clamp(initial.start);
|
||||||
|
end = _clamp(initial.end);
|
||||||
|
}
|
||||||
|
_init(start ?? _clamp(DateTime.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Earliest selectable day.
|
||||||
|
final DateTime first;
|
||||||
|
|
||||||
|
/// Latest selectable day.
|
||||||
|
final DateTime last;
|
||||||
|
|
||||||
|
/// Whether this is the two-tap range mode.
|
||||||
|
final bool range;
|
||||||
|
|
||||||
|
/// The chosen day (single mode) or the range start.
|
||||||
|
DateTime? start;
|
||||||
|
|
||||||
|
/// The range end; null until the second tap.
|
||||||
|
DateTime? end;
|
||||||
|
|
||||||
|
/// The keyboard cursor; equals [start] in single mode.
|
||||||
|
late DateTime cursor;
|
||||||
|
|
||||||
|
/// Shown year.
|
||||||
|
late int year;
|
||||||
|
|
||||||
|
/// Shown month, 1-12.
|
||||||
|
late int month;
|
||||||
|
|
||||||
|
void _init(DateTime at) {
|
||||||
|
cursor = at;
|
||||||
|
year = at.year;
|
||||||
|
month = at.month;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _clamp(DateTime day) {
|
||||||
|
final d = DateUtils.dateOnly(day);
|
||||||
|
if (d.isBefore(first)) return first;
|
||||||
|
return d.isAfter(last) ? last : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [day] lies within the bounds.
|
||||||
|
bool allows(DateTime day) => !day.isBefore(first) && !day.isAfter(last);
|
||||||
|
|
||||||
|
/// Whether any day of month [m] of year [y] lies within the bounds.
|
||||||
|
bool monthAllowed(int y, int m) =>
|
||||||
|
!DateTime(y, m + 1, 0).isBefore(first) && !DateTime(y, m).isAfter(last);
|
||||||
|
|
||||||
|
/// Whether the check may be pressed: always in single mode, after both
|
||||||
|
/// taps in range mode.
|
||||||
|
bool get canConfirm => !range || end != null;
|
||||||
|
|
||||||
|
/// The range chosen so far, once both ends are set.
|
||||||
|
DateTimeRange? get dateRange =>
|
||||||
|
end == null ? null : DateTimeRange(start: start!, end: end!);
|
||||||
|
|
||||||
|
/// A tap on [day]. Out-of-range days are inert. In range mode: the first
|
||||||
|
/// tap sets the start, the second the end (in either order), a third
|
||||||
|
/// restarts from [day].
|
||||||
|
void tap(DateTime day) {
|
||||||
|
if (!allows(day)) return;
|
||||||
|
cursor = day;
|
||||||
|
if (!range) {
|
||||||
|
start = day;
|
||||||
|
} else if (start == null || end != null) {
|
||||||
|
start = day;
|
||||||
|
end = null;
|
||||||
|
} else if (day.isBefore(start!)) {
|
||||||
|
end = start;
|
||||||
|
start = day;
|
||||||
|
} else {
|
||||||
|
end = day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the cursor by [days], following it into another month. Stops at
|
||||||
|
/// the bounds. In single mode the selection moves with it.
|
||||||
|
void moveCursor(int days) {
|
||||||
|
final day = DateTime(cursor.year, cursor.month, cursor.day + days);
|
||||||
|
if (!allows(day)) return;
|
||||||
|
_init(day);
|
||||||
|
if (!range) start = day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows month [m] of the shown year.
|
||||||
|
void showMonth(int m) => _show(year, m);
|
||||||
|
|
||||||
|
/// Shows year [y], keeping the month where the bounds allow.
|
||||||
|
void showYear(int y) => _show(y, month);
|
||||||
|
|
||||||
|
/// Shows ([y], [m]), pulling the month into bounds and the cursor day to
|
||||||
|
/// the new month's last day where it is shorter. A range's chosen ends
|
||||||
|
/// never move.
|
||||||
|
void _show(int y, int m) {
|
||||||
|
var shown = m;
|
||||||
|
if (y == first.year && shown < first.month) shown = first.month;
|
||||||
|
if (y == last.year && shown > last.month) shown = last.month;
|
||||||
|
final day = math.min(cursor.day, DateUtils.getDaysInMonth(y, shown));
|
||||||
|
year = y;
|
||||||
|
month = shown;
|
||||||
|
cursor = _clamp(DateTime(y, shown, day));
|
||||||
|
if (!range) start = cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/lib/widgets/pickers/loop_wheel_delegate.dart
Normal file
30
app/lib/widgets/pickers/loop_wheel_delegate.dart
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/// The endless child delegate of the looping time wheels.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// An endless delegate that passes the absolute index to its builder, so a
|
||||||
|
/// tapped item knows which of its many copies it is (the framework's
|
||||||
|
/// looping delegate only hands out the wrapped child).
|
||||||
|
class LoopWheelDelegate extends ListWheelChildDelegate {
|
||||||
|
/// Creates a delegate looping over [count] values.
|
||||||
|
LoopWheelDelegate(this.count, this.builder);
|
||||||
|
|
||||||
|
/// Number of distinct values before the wheel wraps.
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
/// Builds the item at an absolute (unwrapped) index.
|
||||||
|
final Widget Function(int index) builder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget? build(BuildContext context, int index) => builder(index);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int? get estimatedChildCount => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int trueIndexOf(int index) => index % count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(covariant ListWheelChildDelegate oldDelegate) => true;
|
||||||
|
}
|
||||||
46
app/lib/widgets/pickers/month_chips.dart
Normal file
46
app/lib/widgets/pickers/month_chips.dart
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/// The month row of the date picker: numerals 1-12 instead of month names.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/numeral_cell.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Twelve numeral chips in two rows of six; months wholly outside the bounds
|
||||||
|
/// are dimmed and inert.
|
||||||
|
class MonthChips extends StatelessWidget {
|
||||||
|
/// Creates a [MonthChips].
|
||||||
|
const MonthChips({
|
||||||
|
required this.year,
|
||||||
|
required this.selected,
|
||||||
|
required this.allowed,
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The shown year (for the spoken month names).
|
||||||
|
final int year;
|
||||||
|
|
||||||
|
/// The shown month, 1-12.
|
||||||
|
final int selected;
|
||||||
|
|
||||||
|
/// Whether month 1-12 has any day within the bounds.
|
||||||
|
final bool Function(int month) allowed;
|
||||||
|
|
||||||
|
/// Called with a tapped allowed month.
|
||||||
|
final ValueChanged<int> onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = MaterialLocalizations.of(context);
|
||||||
|
return numeralRows(6, aspectRatio: 1.6, [
|
||||||
|
for (var m = 1; m <= 12; m++)
|
||||||
|
NumeralCell(
|
||||||
|
key: ValueKey('month-$m'),
|
||||||
|
label: '$m',
|
||||||
|
semanticLabel: l10n.formatMonthYear(DateTime(year, m)),
|
||||||
|
onTap: () => onTap(m),
|
||||||
|
enabled: allowed(m),
|
||||||
|
selected: m == selected,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/lib/widgets/pickers/numeral_cell.dart
Normal file
153
app/lib/widgets/pickers/numeral_cell.dart
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/// One tappable numeral of the pickers' grids (a day, a month), plus the
|
||||||
|
/// equal-width row layout both grids use.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Alpha of a weekend numeral: dimmed, with a dot as the second channel.
|
||||||
|
const double kWeekendAlpha = 0.6;
|
||||||
|
|
||||||
|
/// Alpha of an out-of-range numeral: far below [kWeekendAlpha], so an
|
||||||
|
/// out-of-range weekend never reads like an in-range one.
|
||||||
|
const double kDisabledAlpha = 0.25;
|
||||||
|
|
||||||
|
/// Alpha of the shaded band between two range ends.
|
||||||
|
const double kBandAlpha = 0.3;
|
||||||
|
|
||||||
|
/// A numeral in a stadium. Every visual state is a field, so tests assert
|
||||||
|
/// state rather than colours.
|
||||||
|
class NumeralCell extends StatelessWidget {
|
||||||
|
/// Creates a [NumeralCell].
|
||||||
|
const NumeralCell({
|
||||||
|
required this.label,
|
||||||
|
required this.semanticLabel,
|
||||||
|
required this.onTap,
|
||||||
|
this.enabled = true,
|
||||||
|
this.selected = false,
|
||||||
|
this.band = false,
|
||||||
|
this.ring = false,
|
||||||
|
this.dot = false,
|
||||||
|
this.focused = false,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The painted numeral.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// The spoken name (a full date, a month); never painted.
|
||||||
|
final String semanticLabel;
|
||||||
|
|
||||||
|
/// Called on tap when [enabled].
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// Out-of-range cells are dimmed and inert.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// Filled: the chosen value (a range end is a filled cap).
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
|
/// Shaded background: inside a chosen range.
|
||||||
|
final bool band;
|
||||||
|
|
||||||
|
/// Outlined: today.
|
||||||
|
final bool ring;
|
||||||
|
|
||||||
|
/// Dimmed with a dot below: a weekend.
|
||||||
|
final bool dot;
|
||||||
|
|
||||||
|
/// The keyboard cursor.
|
||||||
|
final bool focused;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final alpha = !enabled ? kDisabledAlpha : (dot ? kWeekendAlpha : 1.0);
|
||||||
|
final fg = selected
|
||||||
|
? scheme.onPrimary
|
||||||
|
: scheme.onSurface.withValues(alpha: alpha);
|
||||||
|
final edge = ring || focused
|
||||||
|
? BorderSide(color: focused ? scheme.outline : scheme.primary, width: 2)
|
||||||
|
: BorderSide.none;
|
||||||
|
final shape = StadiumBorder(side: edge);
|
||||||
|
return Semantics(
|
||||||
|
label: semanticLabel,
|
||||||
|
button: true,
|
||||||
|
selected: selected,
|
||||||
|
enabled: enabled,
|
||||||
|
// excludeSemantics drops the InkWell's own tap action, so the node
|
||||||
|
// offers it here or a screen reader cannot pick a day.
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
excludeSemantics: true,
|
||||||
|
child: ColoredBox(
|
||||||
|
// Translucent, not primaryContainer: in this theme that equals
|
||||||
|
// primary, which hid the filled range ends inside the band.
|
||||||
|
color: band
|
||||||
|
? scheme.primary.withValues(alpha: kBandAlpha)
|
||||||
|
: Colors.transparent,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: shape,
|
||||||
|
canRequestFocus: false,
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
child: Ink(
|
||||||
|
decoration: ShapeDecoration(
|
||||||
|
shape: shape,
|
||||||
|
color: selected ? scheme.primary : null,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fg,
|
||||||
|
fontWeight: selected ? FontWeight.bold : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (dot)
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fg,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lays [cells] out in rows of [columns] equal-width cells, each
|
||||||
|
/// [aspectRatio] wide per unit of height; a short last row is padded.
|
||||||
|
Widget numeralRows(int columns, List<Widget> cells, {double aspectRatio = 1}) =>
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (var r = 0; r < cells.length; r += columns)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
for (var c = r; c < r + columns; c++)
|
||||||
|
Expanded(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
child: c < cells.length
|
||||||
|
? cells[c]
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
238
app/lib/widgets/pickers/numeral_wheel.dart
Normal file
238
app/lib/widgets/pickers/numeral_wheel.dart
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
/// A controlled numeral wheel: the year wheel and both time wheels.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/loop_wheel_delegate.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/numeral_cell.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Height of one wheel item.
|
||||||
|
const double kWheelItemExtent = 40;
|
||||||
|
|
||||||
|
/// A wheel of the values `offset .. offset + count - 1`, optionally looping.
|
||||||
|
///
|
||||||
|
/// Controlled: [value] is the truth and the wheel animates to it when the
|
||||||
|
/// parent changes it. Mouse drag scrolls (web excludes the mouse from drag by
|
||||||
|
/// default), a tap on any item selects it, arrow keys step by one. Values
|
||||||
|
/// failing [enabled] are dimmed and inert: [onChanged] never reports them and
|
||||||
|
/// a wheel that settles on one springs back to [value].
|
||||||
|
class NumeralWheel extends StatefulWidget {
|
||||||
|
/// Creates a [NumeralWheel].
|
||||||
|
const NumeralWheel({
|
||||||
|
required this.count,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.semanticLabel,
|
||||||
|
this.offset = 0,
|
||||||
|
this.loop = false,
|
||||||
|
this.digits = 1,
|
||||||
|
this.enabled,
|
||||||
|
this.autofocus = false,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Number of values.
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
/// The value at index 0.
|
||||||
|
final int offset;
|
||||||
|
|
||||||
|
/// The current value.
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
/// Called with each newly centred allowed value.
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
/// The spoken name of the wheel ("Year"); never painted.
|
||||||
|
final String semanticLabel;
|
||||||
|
|
||||||
|
/// Whether the last value wraps to the first.
|
||||||
|
final bool loop;
|
||||||
|
|
||||||
|
/// Zero-padded width of each numeral (2 for `05`).
|
||||||
|
final int digits;
|
||||||
|
|
||||||
|
/// Which values may be picked; all when null.
|
||||||
|
final bool Function(int value)? enabled;
|
||||||
|
|
||||||
|
/// Whether the wheel takes keyboard focus when it appears.
|
||||||
|
final bool autofocus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NumeralWheel> createState() => _NumeralWheelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NumeralWheelState extends State<NumeralWheel> {
|
||||||
|
late final FixedExtentScrollController _controller =
|
||||||
|
FixedExtentScrollController(initialItem: widget.value - widget.offset);
|
||||||
|
late int _centred = widget.value - widget.offset;
|
||||||
|
bool _focused = false;
|
||||||
|
|
||||||
|
int _valueAt(int index) =>
|
||||||
|
widget.offset + (widget.loop ? index % widget.count : index);
|
||||||
|
|
||||||
|
bool _ok(int value) => widget.enabled?.call(value) ?? true;
|
||||||
|
|
||||||
|
/// The index nearest the centre that shows [value] (a loop has many).
|
||||||
|
int _indexOf(int value) {
|
||||||
|
final target = value - widget.offset;
|
||||||
|
if (!widget.loop) return target;
|
||||||
|
final n = widget.count;
|
||||||
|
final delta = (target - _centred) % n;
|
||||||
|
return delta > n ~/ 2 ? _centred + delta - n : _centred + delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goTo(int index) => _controller.animateToItem(
|
||||||
|
index,
|
||||||
|
duration: AppDuration.fast,
|
||||||
|
curve: AppCurve.standard,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get _scrolling =>
|
||||||
|
_controller.hasClients && _controller.position.isScrollingNotifier.value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(NumeralWheel old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
// Not mid-drag: the parent echoing an intermediate value must not yank
|
||||||
|
// the wheel out from under the pointer.
|
||||||
|
if (widget.value != _valueAt(_centred) && !_scrolling) {
|
||||||
|
_afterFrame(() => _goTo(_indexOf(widget.value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _afterFrame(VoidCallback action) =>
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) action();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The allowed value nearest [value], by value and never across the wrap
|
||||||
|
/// (08:00 out of range means 08:15, not 08:59). A fast drag can skip every
|
||||||
|
/// allowed value on its way past an end, so the last reported value is no
|
||||||
|
/// anchor.
|
||||||
|
int _nearestAllowed(int value) {
|
||||||
|
final last = widget.offset + widget.count - 1;
|
||||||
|
for (var d = 1; d < widget.count; d++) {
|
||||||
|
for (final v in [value - d, value + d]) {
|
||||||
|
if (v >= widget.offset && v <= last && _ok(v)) return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widget.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _centre(int index) {
|
||||||
|
setState(() => _centred = index);
|
||||||
|
final value = _valueAt(index);
|
||||||
|
if (_ok(value) && value != widget.value) widget.onChanged(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _key(FocusNode _, KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
final step = switch (event.logicalKey) {
|
||||||
|
LogicalKeyboardKey.arrowUp => -1,
|
||||||
|
LogicalKeyboardKey.arrowDown => 1,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if (step == 0) return KeyEventResult.ignored;
|
||||||
|
final next = _centred + step;
|
||||||
|
final inList = widget.loop || (next >= 0 && next < widget.count);
|
||||||
|
if (inList && _ok(_valueAt(next))) _goTo(next);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _item(BuildContext context, int index) {
|
||||||
|
final value = _valueAt(index);
|
||||||
|
final ok = _ok(value);
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final centred = index == _centred;
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: ok ? () => _goTo(index) : null,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$value'.padLeft(widget.digits, '0'),
|
||||||
|
key: ValueKey('wheel-$value'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: centred ? AppTextSize.title : AppTextSize.subtitle,
|
||||||
|
fontWeight: centred ? FontWeight.bold : null,
|
||||||
|
color: scheme.onSurface.withValues(alpha: ok ? 1 : kDisabledAlpha),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final behavior = ScrollConfiguration.of(context).copyWith(
|
||||||
|
dragDevices: PointerDeviceKind.values.toSet(),
|
||||||
|
scrollbars: false,
|
||||||
|
);
|
||||||
|
return Semantics(
|
||||||
|
label: widget.semanticLabel,
|
||||||
|
container: true,
|
||||||
|
child: Focus(
|
||||||
|
autofocus: widget.autofocus,
|
||||||
|
onKeyEvent: _key,
|
||||||
|
onFocusChange: (f) => setState(() => _focused = f),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: kWheelItemExtent,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.primaryContainer,
|
||||||
|
border: _focused
|
||||||
|
? Border.all(color: scheme.outline, width: 2)
|
||||||
|
: null,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
NotificationListener<ScrollEndNotification>(
|
||||||
|
onNotification: (_) {
|
||||||
|
if (!_ok(_valueAt(_centred))) {
|
||||||
|
_afterFrame(
|
||||||
|
() => _goTo(_indexOf(_nearestAllowed(_valueAt(_centred)))),
|
||||||
|
);
|
||||||
|
} else if (_valueAt(_centred) != widget.value) {
|
||||||
|
_afterFrame(() => _goTo(_indexOf(widget.value)));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ScrollConfiguration(
|
||||||
|
behavior: behavior,
|
||||||
|
child: ListWheelScrollView.useDelegate(
|
||||||
|
controller: _controller,
|
||||||
|
itemExtent: kWheelItemExtent,
|
||||||
|
physics: const FixedExtentScrollPhysics(),
|
||||||
|
diameterRatio: 1.6,
|
||||||
|
onSelectedItemChanged: _centre,
|
||||||
|
childDelegate: widget.loop
|
||||||
|
? LoopWheelDelegate(
|
||||||
|
widget.count,
|
||||||
|
(i) => _item(context, i),
|
||||||
|
)
|
||||||
|
: ListWheelChildBuilderDelegate(
|
||||||
|
childCount: widget.count,
|
||||||
|
builder: _item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/lib/widgets/pickers/picker_frame.dart
Normal file
75
app/lib/widgets/pickers/picker_frame.dart
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/// The shared dialog around every picker: a centred, width-capped [Dialog]
|
||||||
|
/// with a close icon (cancel) and a check icon (confirm), no words.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Picker chrome. Outside tap and Esc cancel (the dialog route's own
|
||||||
|
/// dismiss); Enter confirms when [canConfirm].
|
||||||
|
///
|
||||||
|
/// The icons carry spoken-only `semanticLabel`s and no `tooltip:`, which
|
||||||
|
/// would paint the word on long-press.
|
||||||
|
class PickerFrame extends StatelessWidget {
|
||||||
|
/// Creates a [PickerFrame].
|
||||||
|
const PickerFrame({
|
||||||
|
required this.child,
|
||||||
|
required this.canConfirm,
|
||||||
|
required this.onConfirm,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The picker body.
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// Whether the check is enabled (a half-chosen range is not).
|
||||||
|
final bool canConfirm;
|
||||||
|
|
||||||
|
/// Pops the dialog with the chosen value.
|
||||||
|
final VoidCallback onConfirm;
|
||||||
|
|
||||||
|
void _enter() {
|
||||||
|
if (canConfirm) onConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: AppWidth.picker),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Around the body only: Enter on a focused close icon must
|
||||||
|
// reach that button (cancel), not bubble up into a confirm.
|
||||||
|
CallbackShortcuts(
|
||||||
|
bindings: {
|
||||||
|
const SingleActivator(LogicalKeyboardKey.enter): _enter,
|
||||||
|
const SingleActivator(LogicalKeyboardKey.numpadEnter): _enter,
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
spacing: AppSpacing.sm,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||||
|
),
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: canConfirm ? onConfirm : null,
|
||||||
|
icon: const Icon(Icons.check, semanticLabel: 'Confirm'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
67
app/lib/widgets/pickers/pickers.dart
Normal file
67
app/lib/widgets/pickers/pickers.dart
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/// Icon-only replacements for `showDatePicker`, `showDateRangePicker` and
|
||||||
|
/// `showTimePicker`, which paint "Select date", "Cancel", "OK" and tooltips.
|
||||||
|
///
|
||||||
|
/// Same arguments as the framework calls, so a call site changes by one
|
||||||
|
/// function name. Dropped on purpose: the weekday header, month names and
|
||||||
|
/// both text-entry modes (see the textgate rework report).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_dialog.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/time_dialog.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Picks one day in [firstDate]..[lastDate]; null when cancelled.
|
||||||
|
///
|
||||||
|
/// [initialDatePickerMode] `year` focuses the year wheel first.
|
||||||
|
Future<DateTime?> pickDate({
|
||||||
|
required BuildContext context,
|
||||||
|
required DateTime firstDate,
|
||||||
|
required DateTime lastDate,
|
||||||
|
DateTime? initialDate,
|
||||||
|
DatePickerMode initialDatePickerMode = DatePickerMode.day,
|
||||||
|
}) => showDialog<DateTime>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => DateDialog(
|
||||||
|
selection: DateSelection.single(
|
||||||
|
first: firstDate,
|
||||||
|
last: lastDate,
|
||||||
|
initial: initialDate,
|
||||||
|
),
|
||||||
|
startOnYear: initialDatePickerMode == DatePickerMode.year,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Picks a range of days in [firstDate]..[lastDate] by two taps; null when
|
||||||
|
/// cancelled.
|
||||||
|
Future<DateTimeRange?> pickDateRange({
|
||||||
|
required BuildContext context,
|
||||||
|
required DateTime firstDate,
|
||||||
|
required DateTime lastDate,
|
||||||
|
DateTimeRange? initialDateRange,
|
||||||
|
}) => showDialog<DateTimeRange>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => DateDialog(
|
||||||
|
selection: DateSelection.range(
|
||||||
|
first: firstDate,
|
||||||
|
last: lastDate,
|
||||||
|
initial: initialDateRange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Picks a 24 h time whose minute of day lies in [minMinute]..[maxMinute];
|
||||||
|
/// null when cancelled.
|
||||||
|
Future<TimeOfDay?> pickTime({
|
||||||
|
required BuildContext context,
|
||||||
|
required TimeOfDay initialTime,
|
||||||
|
int minMinute = 0,
|
||||||
|
int maxMinute = 24 * 60 - 1,
|
||||||
|
}) => showDialog<TimeOfDay>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => TimeDialog(
|
||||||
|
initialMinute: initialTime.hour * 60 + initialTime.minute,
|
||||||
|
minMinute: minMinute,
|
||||||
|
maxMinute: maxMinute,
|
||||||
|
),
|
||||||
|
);
|
||||||
52
app/lib/widgets/pickers/time_dialog.dart
Normal file
52
app/lib/widgets/pickers/time_dialog.dart
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/// The time dialog: hour and minute wheels between check and close icons.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/picker_frame.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/time_wheels.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Pops the chosen [TimeOfDay], always within [minMinute]..[maxMinute].
|
||||||
|
class TimeDialog extends StatefulWidget {
|
||||||
|
/// Creates a [TimeDialog]; [initialMinute] is clamped into range, since a
|
||||||
|
/// value written by another device may lie outside it.
|
||||||
|
const TimeDialog({
|
||||||
|
required this.initialMinute,
|
||||||
|
required this.minMinute,
|
||||||
|
required this.maxMinute,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The starting minute of day.
|
||||||
|
final int initialMinute;
|
||||||
|
|
||||||
|
/// Earliest allowed minute of day.
|
||||||
|
final int minMinute;
|
||||||
|
|
||||||
|
/// Latest allowed minute of day.
|
||||||
|
final int maxMinute;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TimeDialog> createState() => _TimeDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimeDialogState extends State<TimeDialog> {
|
||||||
|
late int _minute = clampMinute(
|
||||||
|
widget.initialMinute,
|
||||||
|
widget.minMinute,
|
||||||
|
widget.maxMinute,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => PickerFrame(
|
||||||
|
canConfirm: true,
|
||||||
|
onConfirm: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(TimeOfDay(hour: _minute ~/ 60, minute: _minute % 60)),
|
||||||
|
child: TimeWheels(
|
||||||
|
minute: _minute,
|
||||||
|
minMinute: widget.minMinute,
|
||||||
|
maxMinute: widget.maxMinute,
|
||||||
|
onChanged: (m) => setState(() => _minute = m),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
91
app/lib/widgets/pickers/time_wheels.dart
Normal file
91
app/lib/widgets/pickers/time_wheels.dart
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/// The time picker's two looping wheels: hour 00-23 and minute 00-59, 24 h.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/numeral_wheel.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Width of one time wheel.
|
||||||
|
const double kTimeWheelWidth = 72;
|
||||||
|
|
||||||
|
/// Whether hour [h] holds any minute of day in [min]..[max].
|
||||||
|
bool hourAllowed(int h, int min, int max) =>
|
||||||
|
h * 60 + 59 >= min && h * 60 <= max;
|
||||||
|
|
||||||
|
/// [minute] of day pulled into [min]..[max].
|
||||||
|
int clampMinute(int minute, int min, int max) => minute.clamp(min, max);
|
||||||
|
|
||||||
|
/// Hour and minute wheels for a minute of day within [minMinute]..[maxMinute].
|
||||||
|
///
|
||||||
|
/// Hours and minutes outside the range are dimmed and inert. Changing the
|
||||||
|
/// hour pulls the minute to the nearest allowed one in that hour, so the
|
||||||
|
/// value reported is always in range.
|
||||||
|
class TimeWheels extends StatelessWidget {
|
||||||
|
/// Creates a [TimeWheels].
|
||||||
|
const TimeWheels({
|
||||||
|
required this.minute,
|
||||||
|
required this.minMinute,
|
||||||
|
required this.maxMinute,
|
||||||
|
required this.onChanged,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The current minute of day (in range).
|
||||||
|
final int minute;
|
||||||
|
|
||||||
|
/// Earliest allowed minute of day.
|
||||||
|
final int minMinute;
|
||||||
|
|
||||||
|
/// Latest allowed minute of day.
|
||||||
|
final int maxMinute;
|
||||||
|
|
||||||
|
/// Called with each new in-range minute of day.
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hour = minute ~/ 60;
|
||||||
|
Widget wheel(Widget child) => SizedBox(
|
||||||
|
width: kTimeWheelWidth,
|
||||||
|
height: kWheelItemExtent * 5,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: AppSpacing.xs,
|
||||||
|
children: [
|
||||||
|
wheel(
|
||||||
|
NumeralWheel(
|
||||||
|
key: const ValueKey('hour'),
|
||||||
|
count: 24,
|
||||||
|
value: hour,
|
||||||
|
loop: true,
|
||||||
|
digits: 2,
|
||||||
|
semanticLabel: 'Hour',
|
||||||
|
autofocus: true,
|
||||||
|
enabled: (h) => hourAllowed(h, minMinute, maxMinute),
|
||||||
|
onChanged: (h) => onChanged(
|
||||||
|
clampMinute(h * 60 + minute % 60, minMinute, maxMinute),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(':', style: TextStyle(fontSize: AppTextSize.title)),
|
||||||
|
wheel(
|
||||||
|
NumeralWheel(
|
||||||
|
key: const ValueKey('minute'),
|
||||||
|
count: 60,
|
||||||
|
value: minute % 60,
|
||||||
|
loop: true,
|
||||||
|
digits: 2,
|
||||||
|
semanticLabel: 'Minute',
|
||||||
|
enabled: (m) {
|
||||||
|
final v = hour * 60 + m;
|
||||||
|
return v >= minMinute && v <= maxMinute;
|
||||||
|
},
|
||||||
|
onChanged: (m) => onChanged(hour * 60 + m),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/lib/widgets/pickers/year_wheel.dart
Normal file
50
app/lib/widgets/pickers/year_wheel.dart
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/// The date picker's year wheel: numerals, no loop.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/numeral_wheel.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Out-of-range years listed beyond each bound, dimmed and inert, so the
|
||||||
|
/// wheel's ends read as limits rather than as a list that just stops.
|
||||||
|
const int kYearWheelPad = 2;
|
||||||
|
|
||||||
|
/// A non-looping wheel of the years [firstYear]..[lastYear], plus
|
||||||
|
/// [kYearWheelPad] dimmed, inert years past each end (a wheel dragged onto
|
||||||
|
/// one springs back).
|
||||||
|
class YearWheel extends StatelessWidget {
|
||||||
|
/// Creates a [YearWheel].
|
||||||
|
const YearWheel({
|
||||||
|
required this.firstYear,
|
||||||
|
required this.lastYear,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.autofocus = false,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Earliest listed year.
|
||||||
|
final int firstYear;
|
||||||
|
|
||||||
|
/// Latest listed year.
|
||||||
|
final int lastYear;
|
||||||
|
|
||||||
|
/// The shown year.
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
/// Called with a newly centred year.
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
/// Whether the wheel takes keyboard focus on open (the birth-date flow).
|
||||||
|
final bool autofocus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => NumeralWheel(
|
||||||
|
count: lastYear - firstYear + 1 + 2 * kYearWheelPad,
|
||||||
|
offset: firstYear - kYearWheelPad,
|
||||||
|
enabled: (y) => y >= firstYear && y <= lastYear,
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
semanticLabel: 'Year',
|
||||||
|
autofocus: autofocus,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,13 +2,15 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/day_status.dart';
|
import 'package:diet_guard_app/models/day_status.dart';
|
||||||
|
import 'package:diet_guard_app/ui/theme.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Displays the logging streak, adherence streak, and year-to-date tally.
|
/// Displays the logging streak, adherence streak, and year-to-date tally.
|
||||||
///
|
///
|
||||||
/// Mirrors `_calendar_view.py`'s `streaks_text`/`ytd_text` formatting:
|
/// Mirrors `_calendar_view.py`'s `streaks_text`/`ytd_text` data, as glyphs and
|
||||||
/// "Logging streak: N day(s) · Adherence streak: N day(s)" and
|
/// numerals: pencil + N (logging streak), flame + N (adherence streak),
|
||||||
/// "This year: logged X/Y days · Z within budget".
|
/// calendar-check + X/Y (days logged of days elapsed), medal + Z (days within
|
||||||
|
/// budget).
|
||||||
class StreakSummaryRow extends StatelessWidget {
|
class StreakSummaryRow extends StatelessWidget {
|
||||||
/// Creates a [StreakSummaryRow].
|
/// Creates a [StreakSummaryRow].
|
||||||
const StreakSummaryRow({
|
const StreakSummaryRow({
|
||||||
@ -27,27 +29,80 @@ class StreakSummaryRow extends StatelessWidget {
|
|||||||
/// This year's logged/elapsed/adherent day counts.
|
/// This year's logged/elapsed/adherent day counts.
|
||||||
final YtdTally tally;
|
final YtdTally tally;
|
||||||
|
|
||||||
static String _plural(int count) => count == 1 ? 'day' : 'days';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final status = theme.extension<AppStatusColors>() ?? AppStatusColors.dark;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Wrap(
|
||||||
'Logging streak: $loggingStreak ${_plural(loggingStreak)} · '
|
alignment: WrapAlignment.center,
|
||||||
'Adherence streak: $adherenceStreak ${_plural(adherenceStreak)}',
|
spacing: AppSpacing.lg,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: theme.textTheme.titleSmall,
|
_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),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text(
|
Wrap(
|
||||||
'This year: logged ${tally.loggedDays}/${tally.elapsedDays} days · '
|
alignment: WrapAlignment.center,
|
||||||
'${tally.adherentDays} within budget',
|
spacing: AppSpacing.lg,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: theme.textTheme.bodySmall,
|
_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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -40,16 +40,22 @@ class SyncHealthBanner extends StatelessWidget {
|
|||||||
border: Border.all(color: warning.withValues(alpha: 0.5)),
|
border: Border.all(color: warning.withValues(alpha: 0.5)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.cloud_off, size: 18, color: warning),
|
Icon(
|
||||||
|
Icons.cloud_off,
|
||||||
|
size: 18,
|
||||||
|
color: warning,
|
||||||
|
semanticLabel: message,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
if (status?.agoShort case final ago?)
|
||||||
child: Text(
|
Text(
|
||||||
message,
|
ago,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: warning),
|
style: theme.textTheme.bodySmall?.copyWith(color: warning),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/slot.dart';
|
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/ui/theme.dart';
|
||||||
import 'package:diet_guard_app/widgets/progress_motion.dart';
|
import 'package:diet_guard_app/widgets/progress_motion.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -87,8 +88,6 @@ class TodayProgressCard extends StatelessWidget {
|
|||||||
/// The values to display.
|
/// The values to display.
|
||||||
final TodayProgress progress;
|
final TodayProgress progress;
|
||||||
|
|
||||||
static String _plural(int count) => count == 1 ? 'day' : 'days';
|
|
||||||
|
|
||||||
static String _grams(double value) => '${value.round()}g';
|
static String _grams(double value) => '${value.round()}g';
|
||||||
|
|
||||||
/// Whole numbers without decimals; fractions exactly, never rounded.
|
/// Whole numbers without decimals; fractions exactly, never rounded.
|
||||||
@ -98,6 +97,7 @@ class TodayProgressCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final status = theme.extension<AppStatusColors>() ?? AppStatusColors.dark;
|
||||||
final slot = progress.loggedSlotMinute;
|
final slot = progress.loggedSlotMinute;
|
||||||
final logged = progress.loggedKcal;
|
final logged = progress.loggedKcal;
|
||||||
final from = progress.previousKcal ?? progress.consumedKcal;
|
final from = progress.previousKcal ?? progress.consumedKcal;
|
||||||
@ -118,31 +118,55 @@ class TodayProgressCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (slot != null && logged != null) ...[
|
if (slot != null && logged != null) ...[
|
||||||
Text(
|
Row(
|
||||||
'✓ ${slotLabel(slot)} · ${_kcal(logged)} kcal',
|
children: [
|
||||||
style: theme.textTheme.labelMedium,
|
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),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
],
|
],
|
||||||
CountUp(from: from, to: progress.consumedKcal, builder: _kcalRow),
|
CountUp(from: from, to: progress.consumedKcal, builder: _kcalRow),
|
||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text(
|
Row(
|
||||||
'P ${_grams(progress.proteinG)} · '
|
children: [
|
||||||
'C ${_grams(progress.carbsG)} · '
|
MacroFigure(
|
||||||
'F ${_grams(progress.fatG)}',
|
kind: MacroKind.protein,
|
||||||
style: theme.textTheme.labelSmall,
|
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),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
CountUp(
|
CountUp(
|
||||||
from: streakFrom.toDouble(),
|
from: streakFrom.toDouble(),
|
||||||
to: progress.adherenceStreak.toDouble(),
|
to: progress.adherenceStreak.toDouble(),
|
||||||
builder: (context, value) {
|
builder: (context, value) => Row(
|
||||||
final days = value.round();
|
children: [
|
||||||
return Text(
|
Icon(
|
||||||
'Adherence streak: $days ${_plural(days)}',
|
Icons.local_fire_department,
|
||||||
style: theme.textTheme.labelSmall,
|
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 colors = theme.colorScheme;
|
||||||
final remaining = progress.budgetKcal - consumed;
|
final remaining = progress.budgetKcal - consumed;
|
||||||
final over = remaining < 0;
|
final over = remaining < 0;
|
||||||
return Row(
|
final tint = over ? colors.error : colors.onSurface;
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
// Bar fill = share of the budget used; it turns red AND the right-hand
|
||||||
textBaseline: TextBaseline.alphabetic,
|
// 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: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'${consumed.round()} / ${progress.budgetKcal}',
|
children: [
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
Icon(
|
||||||
color: over ? colors.error : colors.onSurface,
|
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),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text('kcal', style: theme.textTheme.labelSmall),
|
LinearProgressIndicator(
|
||||||
const Spacer(),
|
value: used,
|
||||||
Text(
|
minHeight: 6,
|
||||||
'${remaining.abs().round()} ${over ? 'over' : 'left'}',
|
borderRadius: BorderRadius.circular(3),
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
color: over ? colors.error : colors.primary,
|
||||||
color: over ? colors.error : colors.onSurfaceVariant,
|
backgroundColor: colors.surfaceContainerHighest,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,10 +5,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: android_file_picker
|
name: android_file_picker
|
||||||
sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8"
|
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -157,11 +157,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
ref: "crdt_sync_dart-v0.11.3"
|
ref: "crdt_sync_dart-v0.12.1"
|
||||||
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
|
resolved-ref: "6641d85f0550ca60433fc8e0d13a3dafdbb664d6"
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.11.3"
|
version: "0.12.1"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -190,11 +190,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: data_transfer_flutter
|
path: data_transfer_flutter
|
||||||
ref: "data_transfer_flutter-v0.2.0"
|
ref: "data_transfer_flutter-v0.2.1"
|
||||||
resolved-ref: bbb7f0f0c0cd891518418472955c8a2ac6cbca4d
|
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.2.0"
|
version: "0.2.1"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -263,18 +263,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592"
|
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "13.3.1"
|
version: "13.3.2"
|
||||||
file_picker_darwin:
|
file_picker_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: file_picker_darwin
|
name: file_picker_darwin
|
||||||
sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54
|
sha256: daeb0219f6279c73eb9c63ae9a566beef04ae13d59b8feba0be2a7f3b258d3d4
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2"
|
||||||
file_picker_linux:
|
file_picker_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -375,10 +375,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: fl_chart
|
name: fl_chart
|
||||||
sha256: "5ed69b8be550c6276c8f0bc25c09704fb061018ed1b1681867ad18fcf477e7b7"
|
sha256: "9f11b0b87a6b1843f5ca666ade4a62c08da1a6b7f9efeb91bb2c6f3fa6e31a9a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -396,10 +396,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: e99e9c6078d1359ba131b2c1eb8d68837170836a01bf2cea3cf2fa13e62ac2ac
|
sha256: "58f9ea245d7194363c05f7a012bbc73291736c2d8b5f555a8e0ea3f9121868b4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "23.0.0"
|
version: "23.0.1"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -428,10 +428,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_windows
|
name: flutter_local_notifications_windows
|
||||||
sha256: c2d6efe9ba585d5f560d8b329131f00fe745834848e6221b22d1f62e0f0b16e2
|
sha256: "8e80ac29433c71e267493b397457e3a8430c5c1d9c76bb58a6971c016cdba3d7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.1"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -544,10 +544,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: idb_shim
|
name: idb_shim
|
||||||
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b"
|
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.11.2"
|
version: "2.11.3"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -824,10 +824,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sembast
|
name: sembast
|
||||||
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a"
|
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.10.0"
|
version: "3.10.1"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -941,11 +941,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: sync_settings_ui
|
path: sync_settings_ui
|
||||||
ref: "sync_settings_ui-v0.5.2"
|
ref: "sync_settings_ui-v0.6.2"
|
||||||
resolved-ref: "648bde44b1756485d95ca3c3a8714cd2015d5be4"
|
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488"
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.5.2"
|
version: "0.6.2"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
|||||||
description: "Companion phone app for diet_guard: log meals on the go."
|
description: "Companion phone app for diet_guard: log meals on the go."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.136+1
|
version: 1.0.155+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@ -12,7 +12,7 @@ dependencies:
|
|||||||
crdt_sync:
|
crdt_sync:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: crdt_sync_dart-v0.11.3
|
ref: crdt_sync_dart-v0.12.1
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
crypto: 3.0.8
|
crypto: 3.0.8
|
||||||
cryptography: 2.9.0
|
cryptography: 2.9.0
|
||||||
@ -21,12 +21,12 @@ dependencies:
|
|||||||
data_transfer_flutter:
|
data_transfer_flutter:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: data_transfer_flutter-v0.2.0
|
ref: data_transfer_flutter-v0.2.1
|
||||||
path: data_transfer_flutter
|
path: data_transfer_flutter
|
||||||
fl_chart: 1.3.0
|
fl_chart: 1.3.1
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_local_notifications: 23.0.0
|
flutter_local_notifications: 23.0.1
|
||||||
flutter_secure_storage: 11.2.0
|
flutter_secure_storage: 11.2.0
|
||||||
|
|
||||||
# One-tap sign-in for the sync account. App-side only: crdt_sync stays pure
|
# One-tap sign-in for the sync account. App-side only: crdt_sync stays pure
|
||||||
@ -44,7 +44,7 @@ dependencies:
|
|||||||
path: google_account_token
|
path: google_account_token
|
||||||
health: 13.3.2
|
health: 13.3.2
|
||||||
http: 1.6.0
|
http: 1.6.0
|
||||||
idb_shim: 2.11.2
|
idb_shim: 2.11.3
|
||||||
path: 1.9.1
|
path: 1.9.1
|
||||||
path_provider: 2.1.6
|
path_provider: 2.1.6
|
||||||
permission_handler: 13.0.2
|
permission_handler: 13.0.2
|
||||||
@ -57,7 +57,7 @@ dependencies:
|
|||||||
sync_settings_ui:
|
sync_settings_ui:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: sync_settings_ui-v0.5.2
|
ref: sync_settings_ui-v0.6.2
|
||||||
path: sync_settings_ui
|
path: sync_settings_ui
|
||||||
url_launcher: 6.3.3
|
url_launcher: 6.3.3
|
||||||
uuid: 4.6.0
|
uuid: 4.6.0
|
||||||
|
|||||||
153
app/test/desktop/desktop_wrapper_no_text_test.dart
Normal file
153
app/test/desktop/desktop_wrapper_no_text_test.dart
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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;
|
library;
|
||||||
|
|
||||||
import 'package:diet_guard_app/widgets/fill_all_row.dart';
|
import 'package:diet_guard_app/widgets/fill_all_row.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'label_finders.dart';
|
||||||
|
|
||||||
Future<void> _pump(
|
Future<void> _pump(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
bool canFetch = true,
|
bool canFetch = true,
|
||||||
@ -31,26 +33,30 @@ void main() {
|
|||||||
await _pump(t, canFetch: false, armed: 3);
|
await _pump(t, canFetch: false, armed: 3);
|
||||||
|
|
||||||
expect(find.byType(OutlinedButton), findsNothing);
|
expect(find.byType(OutlinedButton), findsNothing);
|
||||||
expect(find.text(fillAllLabel), findsNothing);
|
expect(byIconLabel(fillAllLabel), findsNothing);
|
||||||
expect(find.text('Cancel'), 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);
|
await _pump(t);
|
||||||
|
|
||||||
expect(find.text('🍱 Fill all with catering'), findsOneWidget);
|
expect(byIconLabel(fillAllLabel), findsOneWidget);
|
||||||
expect(find.text('Cancel'), findsNothing);
|
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);
|
await _pump(t, armed: 3);
|
||||||
|
|
||||||
expect(find.text('✓ Confirm (3)'), findsOneWidget);
|
expect(byIconLabel('Confirm'), findsOneWidget);
|
||||||
expect(find.text(fillAllLabel), findsNothing);
|
expect(find.text('3'), findsOneWidget);
|
||||||
expect(find.text('Cancel'), 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 fills = 0;
|
||||||
var cancels = 0;
|
var cancels = 0;
|
||||||
await _pump(
|
await _pump(
|
||||||
@ -61,15 +67,15 @@ void main() {
|
|||||||
onCancel: () => cancels++,
|
onCancel: () => cancels++,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('Working…'), findsOneWidget);
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
expect(find.text('✓ Confirm (3)'), findsNothing);
|
expect(byIconLabel('Confirm'), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
t.widget<OutlinedButton>(find.byType(OutlinedButton)).onPressed,
|
t.widget<OutlinedButton>(find.byType(OutlinedButton)).onPressed,
|
||||||
isNull,
|
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(OutlinedButton), warnIfMissed: false);
|
||||||
await t.tap(find.byType(TextButton), warnIfMissed: false);
|
await t.tap(find.byType(IconButton), warnIfMissed: false);
|
||||||
expect(fills + cancels, 0);
|
expect(fills + cancels, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,8 +89,8 @@ void main() {
|
|||||||
onCancel: () => cancels++,
|
onCancel: () => cancels++,
|
||||||
);
|
);
|
||||||
|
|
||||||
await t.tap(find.text('✓ Confirm (2)'));
|
await t.tap(byIconLabel('Confirm'));
|
||||||
await t.tap(find.text('Cancel'));
|
await t.tap(byIconLabel('Cancel'));
|
||||||
|
|
||||||
expect([fills, cancels], [1, 1]);
|
expect([fills, cancels], [1, 1]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import 'package:diet_guard_app/widgets/day_status_calendar.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/semantics.dart';
|
import 'package:flutter/semantics.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'label_finders.dart';
|
||||||
|
|
||||||
/// Landscape and *short* — the supported floor. Phone-portrait sizes, which is
|
/// Landscape and *short* — the supported floor. Phone-portrait sizes, which is
|
||||||
/// all the existing suite pinned, exercise neither constraint.
|
/// 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
|
// Delete used to be bound only to onLongPress, which has no keyboard
|
||||||
// equivalent whatsoever.
|
// equivalent whatsoever.
|
||||||
expect(
|
expect(
|
||||||
find.byTooltip('Delete entry'),
|
byIconLabel('Delete entry'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
reason: 'delete needs a tab-reachable control, not long-press only',
|
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
|
// Delete stays id-only to avoid ambiguous time+desc matches, so the
|
||||||
// control must be absent rather than present-and-failing.
|
// control must be absent rather than present-and-failing.
|
||||||
expect(find.byTooltip('Delete entry'), findsNothing);
|
expect(byIconLabel('Delete entry'), findsNothing);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
46
app/test/label_finders.dart
Normal file
46
app/test/label_finders.dart
Normal 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),
|
||||||
|
);
|
||||||
90
app/test/no_words_body_test.dart
Normal file
90
app/test/no_words_body_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
77
app/test/no_words_kuchnia_test.dart
Normal file
77
app/test/no_words_kuchnia_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
103
app/test/no_words_screens_test.dart
Normal file
103
app/test/no_words_screens_test.dart
Normal 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.
|
||||||
|
//
|
||||||
|
// The date/time pickers are not opened here: pickers/no_words_pickers_test.dart
|
||||||
|
// opens each one through its call site.
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
69
app/test/no_words_support.dart
Normal file
69
app/test/no_words_support.dart
Normal 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',
|
||||||
|
);
|
||||||
200
app/test/pickers/date_dialog_test.dart
Normal file
200
app/test/pickers/date_dialog_test.dart
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
// The single-date dialog: grid state, confirm vs cancel, bounds, month and
|
||||||
|
// year navigation, keyboard and mouse, spoken labels.
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_dialog.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/year_wheel.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../label_finders.dart';
|
||||||
|
import 'picker_test_support.dart';
|
||||||
|
|
||||||
|
/// Tuesday 2026-03-10; March 2026 starts on a Sunday.
|
||||||
|
final _today = DateTime(2026, 3, 10);
|
||||||
|
|
||||||
|
DateSelection _sel({DateTime? first, DateTime? last}) => DateSelection.single(
|
||||||
|
first: first ?? DateTime(2024),
|
||||||
|
last: last ?? DateTime(2027, 12, 31),
|
||||||
|
initial: _today,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<Popped> _open(
|
||||||
|
WidgetTester tester,
|
||||||
|
DateSelection sel, {
|
||||||
|
bool startOnYear = false,
|
||||||
|
}) => openDialog(
|
||||||
|
tester,
|
||||||
|
DateDialog(selection: sel, today: _today, startOnYear: startOnYear),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Monday first, no header: the 1st of March 2026 sits 7th', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _open(tester, _sel());
|
||||||
|
final row = tester.getTopLeft(find.byKey(ValueKey(DateTime(2026, 3))));
|
||||||
|
final mon = tester.getTopLeft(find.byKey(ValueKey(DateTime(2026, 3, 2))));
|
||||||
|
expect(mon.dy, greaterThan(row.dy), reason: 'Monday 2nd starts row 2');
|
||||||
|
expect(row.dx, greaterThan(mon.dx + 5 * 30), reason: 'Sunday is last');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('today is ringed, weekends dimmed with a dot', (tester) async {
|
||||||
|
await _open(tester, _sel());
|
||||||
|
expect(cell(tester, _today).ring, isTrue);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 11)).ring, isFalse);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 14)).dot, isTrue, reason: 'Sat');
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 15)).dot, isTrue, reason: 'Sun');
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 16)).dot, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a tap highlights; only the check confirms', (tester) async {
|
||||||
|
final popped = await _open(tester, _sel());
|
||||||
|
await tester.tap(find.byKey(ValueKey(DateTime(2026, 3, 19))));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(popped.closed, isFalse);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 19)).selected, isTrue);
|
||||||
|
expect(cell(tester, _today).selected, isFalse);
|
||||||
|
await confirm(tester);
|
||||||
|
expect(popped.value, DateTime(2026, 3, 19));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final how in ['close icon', 'outside tap', 'Esc']) {
|
||||||
|
testWidgets('$how cancels', (tester) async {
|
||||||
|
final popped = await _open(tester, _sel());
|
||||||
|
await tester.tap(find.byKey(ValueKey(DateTime(2026, 3, 19))));
|
||||||
|
await tester.pump();
|
||||||
|
switch (how) {
|
||||||
|
case 'close icon':
|
||||||
|
await tester.tap(controlByLabel('Cancel'));
|
||||||
|
case 'outside tap':
|
||||||
|
await tester.tapAt(const Offset(4, 4));
|
||||||
|
default:
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
}
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(popped.closed, isTrue);
|
||||||
|
expect(popped.value, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('out-of-range days and months are dimmed and inert', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final sel = _sel(first: DateTime(2026, 3, 5), last: DateTime(2026, 4, 20));
|
||||||
|
await _open(tester, sel);
|
||||||
|
final early = DateTime(2026, 3, 4);
|
||||||
|
expect(cell(tester, early).enabled, isFalse);
|
||||||
|
await tester.tap(find.byKey(ValueKey(early)));
|
||||||
|
await tester.pump();
|
||||||
|
expect(sel.start, _today);
|
||||||
|
expect(cell(tester, 'month-5').enabled, isFalse);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('month-5')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(sel.month, 3);
|
||||||
|
expect(cell(tester, 'month-4').enabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a 31st moved to April lands on the 30th', (tester) async {
|
||||||
|
final sel = _sel();
|
||||||
|
await _open(tester, sel);
|
||||||
|
await tester.tap(find.byKey(ValueKey(DateTime(2026, 3, 31))));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(const ValueKey('month-4')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(cell(tester, DateTime(2026, 4, 30)).selected, isTrue);
|
||||||
|
await confirm(tester);
|
||||||
|
expect(sel.start, DateTime(2026, 4, 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('arrows move by a day and a week, Enter confirms', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final popped = await _open(tester, _sel());
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pump();
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 18)).selected, isTrue);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 18)).focused, isTrue);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(popped.value, DateTime(2026, 3, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a mouse click selects a day', (tester) async {
|
||||||
|
final sel = _sel();
|
||||||
|
await _open(tester, sel);
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(ValueKey(DateTime(2026, 3, 3))),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(sel.start, DateTime(2026, 3, 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the year wheel: mouse drag scrolls, a click selects, no wrap', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final sel = _sel();
|
||||||
|
await _open(tester, sel);
|
||||||
|
await tester.drag(
|
||||||
|
find.byType(YearWheel),
|
||||||
|
const Offset(0, -45),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sel.year, 2027);
|
||||||
|
expect(sel.start, DateTime(2027, 3, 10));
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('wheel-2026')),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sel.year, 2026);
|
||||||
|
await tester.drag(find.byType(YearWheel), const Offset(0, -400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sel.year, 2027, reason: 'the last year; the wheel does not loop');
|
||||||
|
await tester.tap(find.byKey(const ValueKey('wheel-2028')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sel.year, 2027, reason: '2028 is past lastDate: dimmed, inert');
|
||||||
|
await tester.drag(find.byType(YearWheel), const Offset(0, 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sel.year, 2024, reason: 'the first year; no wrap to 2027');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the birth-date flow opens on the year wheel', (tester) async {
|
||||||
|
final sel = _sel();
|
||||||
|
await _open(tester, sel, startOnYear: true);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sel.year, 2025, reason: 'the arrow went to the wheel');
|
||||||
|
expect(sel.start, DateTime(2025, 3, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('icons and cells speak; nothing has a tooltip', (tester) async {
|
||||||
|
await _open(tester, _sel());
|
||||||
|
expect(byIconLabel('Confirm'), findsOneWidget);
|
||||||
|
expect(byIconLabel('Cancel'), findsOneWidget);
|
||||||
|
expect(bySemLabel('Tuesday, March 10, 2026'), findsOneWidget);
|
||||||
|
expect(bySemLabel('Year'), findsOneWidget);
|
||||||
|
expect(find.byType(Tooltip), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a screen reader can tap an in-range day, not an inert one', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final handle = tester.ensureSemantics();
|
||||||
|
await _open(tester, _sel(first: DateTime(2026, 3, 5)));
|
||||||
|
SemanticsData day(int d) => tester
|
||||||
|
.getSemantics(find.byKey(ValueKey(DateTime(2026, 3, d))))
|
||||||
|
.getSemanticsData();
|
||||||
|
expect(day(12).hasAction(SemanticsAction.tap), isTrue);
|
||||||
|
expect(day(4).hasAction(SemanticsAction.tap), isFalse);
|
||||||
|
handle.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
123
app/test/pickers/date_range_dialog_test.dart
Normal file
123
app/test/pickers/date_range_dialog_test.dart
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// The range mode of the date dialog: two taps, shaded span, filled ends,
|
||||||
|
// the check gated on both taps, presets, restarts and navigation.
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_dialog.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'picker_test_support.dart';
|
||||||
|
|
||||||
|
DateSelection _sel([DateTimeRange? initial]) => DateSelection.range(
|
||||||
|
first: DateTime(2026),
|
||||||
|
last: DateTime(2026, 12, 31),
|
||||||
|
initial:
|
||||||
|
initial ??
|
||||||
|
DateTimeRange(start: DateTime(2026, 3, 10), end: DateTime(2026, 3, 10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<Popped> _open(WidgetTester tester, DateSelection sel) =>
|
||||||
|
openDialog(tester, DateDialog(selection: sel, today: DateTime(2026, 3)));
|
||||||
|
|
||||||
|
Future<void> _tapDay(WidgetTester tester, int day) async {
|
||||||
|
await tester.tap(find.byKey(ValueKey(DateTime(2026, 3, day))));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('two taps: check off after one, span shaded after two', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final popped = await _open(
|
||||||
|
tester,
|
||||||
|
DateSelection.range(first: DateTime(2026), last: DateTime(2026, 12, 31)),
|
||||||
|
);
|
||||||
|
expect(confirmButton(tester).onPressed, isNull);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('month-3')));
|
||||||
|
await tester.pump();
|
||||||
|
await _tapDay(tester, 4);
|
||||||
|
expect(confirmButton(tester).onPressed, isNull, reason: 'one tap');
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 4)).selected, isTrue);
|
||||||
|
await _tapDay(tester, 9);
|
||||||
|
expect(confirmButton(tester).onPressed, isNotNull);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 9)).selected, isTrue, reason: 'cap');
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 6)).band, isTrue);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 6)).selected, isFalse);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 10)).band, isFalse);
|
||||||
|
await confirm(tester);
|
||||||
|
final range = popped.value! as DateTimeRange;
|
||||||
|
expect(range.start, DateTime(2026, 3, 4));
|
||||||
|
expect(range.end, DateTime(2026, 3, 9));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('later day first still gives an ordered range', (tester) async {
|
||||||
|
final popped = await _open(tester, _sel());
|
||||||
|
await _tapDay(tester, 20);
|
||||||
|
await _tapDay(tester, 12);
|
||||||
|
await confirm(tester);
|
||||||
|
final range = popped.value! as DateTimeRange;
|
||||||
|
expect((range.start.day, range.end.day), (12, 20));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the same day twice is a valid one-day range', (tester) async {
|
||||||
|
final popped = await _open(tester, _sel());
|
||||||
|
await _tapDay(tester, 5);
|
||||||
|
await _tapDay(tester, 5);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 5)).band, isFalse);
|
||||||
|
await confirm(tester);
|
||||||
|
final range = popped.value! as DateTimeRange;
|
||||||
|
expect(range.start, DateTime(2026, 3, 5));
|
||||||
|
expect(range.end, DateTime(2026, 3, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a third tap restarts and turns the check off', (tester) async {
|
||||||
|
await _open(tester, _sel());
|
||||||
|
await _tapDay(tester, 2);
|
||||||
|
await _tapDay(tester, 6);
|
||||||
|
await _tapDay(tester, 25);
|
||||||
|
expect(confirmButton(tester).onPressed, isNull);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 25)).selected, isTrue);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 4)).band, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a preset range opens shaded with the check on', (tester) async {
|
||||||
|
final sel = _sel(
|
||||||
|
DateTimeRange(start: DateTime(2026, 3, 2), end: DateTime(2026, 3, 9)),
|
||||||
|
);
|
||||||
|
await _open(tester, sel);
|
||||||
|
expect(confirmButton(tester).onPressed, isNotNull);
|
||||||
|
expect(cell(tester, DateTime(2026, 3, 5)).band, isTrue);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('month-7')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(sel.start, DateTime(2026, 3, 2));
|
||||||
|
expect(sel.end, DateTime(2026, 3, 9));
|
||||||
|
expect(confirmButton(tester).onPressed, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('keyboard: arrows move the cursor, Space taps, Enter waits '
|
||||||
|
'for both ends', (tester) async {
|
||||||
|
final popped = await _open(tester, _sel());
|
||||||
|
await _tapDay(tester, 10);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(popped.closed, isFalse, reason: 'one end only');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final range = popped.value! as DateTimeRange;
|
||||||
|
expect((range.start.day, range.end.day), (10, 12));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Esc cancels a half-chosen range', (tester) async {
|
||||||
|
final popped = await _open(tester, _sel());
|
||||||
|
await _tapDay(tester, 3);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(popped.closed, isTrue);
|
||||||
|
expect(popped.value, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
155
app/test/pickers/date_selection_test.dart
Normal file
155
app/test/pickers/date_selection_test.dart
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
// The date picker's selection rules, as plain unit tests.
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/date_selection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
final _first = DateTime(2026, 1, 15);
|
||||||
|
final _last = DateTime(2026, 6, 10);
|
||||||
|
|
||||||
|
DateSelection _single(DateTime initial) =>
|
||||||
|
DateSelection.single(first: _first, last: _last, initial: initial);
|
||||||
|
|
||||||
|
DateSelection _range([DateTimeRange? initial]) =>
|
||||||
|
DateSelection.range(first: _first, last: _last, initial: initial);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('bounds', () {
|
||||||
|
test('an initial day outside the bounds is clamped in', () {
|
||||||
|
expect(_single(DateTime(2025, 12)).start, _first);
|
||||||
|
expect(_single(DateTime(2027)).start, _last);
|
||||||
|
final s = _single(DateTime(2027));
|
||||||
|
expect((s.year, s.month), (2026, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a time of day is dropped from every date', () {
|
||||||
|
final s = DateSelection.single(
|
||||||
|
first: DateTime(2026, 1, 1, 13),
|
||||||
|
last: DateTime(2026, 2, 1, 23, 59),
|
||||||
|
initial: DateTime(2026, 1, 5, 18),
|
||||||
|
);
|
||||||
|
expect(s.first, DateTime(2026));
|
||||||
|
expect(s.last, DateTime(2026, 2));
|
||||||
|
expect(s.start, DateTime(2026, 1, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('out-of-range days are inert', () {
|
||||||
|
final s = _single(DateTime(2026, 3, 3))..tap(DateTime(2026, 1, 14));
|
||||||
|
expect(s.start, DateTime(2026, 3, 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a month is allowed when any of its days is in range', () {
|
||||||
|
final s = _single(DateTime(2026, 3, 3));
|
||||||
|
expect(s.monthAllowed(2026, 1), isTrue);
|
||||||
|
expect(s.monthAllowed(2026, 6), isTrue);
|
||||||
|
expect(s.monthAllowed(2026, 7), isFalse);
|
||||||
|
expect(s.monthAllowed(2025, 12), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('single', () {
|
||||||
|
test('a tap selects without confirming; the check is always on', () {
|
||||||
|
final s = _single(DateTime(2026, 3, 3))..tap(DateTime(2026, 3, 9));
|
||||||
|
expect(s.start, DateTime(2026, 3, 9));
|
||||||
|
expect(s.canConfirm, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 31st moved to a 30-day month lands on the 30th', () {
|
||||||
|
final s = _single(DateTime(2026, 3, 31))..showMonth(4);
|
||||||
|
expect(s.start, DateTime(2026, 4, 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a month change clamps the day into the bounds', () {
|
||||||
|
final s = _single(DateTime(2026, 3, 20))..showMonth(6);
|
||||||
|
expect(s.start, _last);
|
||||||
|
s.showMonth(1);
|
||||||
|
expect(s.start, _first, reason: 'day 10 of January is before the 15th');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a year change pulls the month into the edge year', () {
|
||||||
|
final s = DateSelection.single(
|
||||||
|
first: DateTime(2025, 9),
|
||||||
|
last: DateTime(2026, 3, 31),
|
||||||
|
initial: DateTime(2025, 11, 4),
|
||||||
|
)..showYear(2026);
|
||||||
|
expect((s.year, s.month), (2026, 3));
|
||||||
|
expect(s.start, DateTime(2026, 3, 4));
|
||||||
|
s.showYear(2025);
|
||||||
|
expect((s.year, s.month), (2025, 9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arrows move the selection, across months, stopping at bounds', () {
|
||||||
|
final s = _single(DateTime(2026, 1, 31))..moveCursor(1);
|
||||||
|
expect(s.start, DateTime(2026, 2));
|
||||||
|
expect(s.month, 2);
|
||||||
|
s.moveCursor(-7);
|
||||||
|
expect(s.start, DateTime(2026, 1, 25));
|
||||||
|
s
|
||||||
|
..moveCursor(-7)
|
||||||
|
..moveCursor(-7);
|
||||||
|
expect(s.start, DateTime(2026, 1, 18), reason: 'the 11th is out');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('range', () {
|
||||||
|
test('one tap leaves the check off; two taps turn it on', () {
|
||||||
|
final s = _range()..tap(DateTime(2026, 3, 2));
|
||||||
|
expect(s.canConfirm, isFalse);
|
||||||
|
s.tap(DateTime(2026, 3, 9));
|
||||||
|
expect(s.canConfirm, isTrue);
|
||||||
|
expect(s.dateRange!.start, DateTime(2026, 3, 2));
|
||||||
|
expect(s.dateRange!.end, DateTime(2026, 3, 9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a later day tapped first still gives an ordered range', () {
|
||||||
|
final s = _range()
|
||||||
|
..tap(DateTime(2026, 3, 9))
|
||||||
|
..tap(DateTime(2026, 3, 2));
|
||||||
|
expect(s.dateRange!.start, DateTime(2026, 3, 2));
|
||||||
|
expect(s.dateRange!.end, DateTime(2026, 3, 9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same day twice is a one-day range', () {
|
||||||
|
final s = _range()
|
||||||
|
..tap(DateTime(2026, 3, 4))
|
||||||
|
..tap(DateTime(2026, 3, 4));
|
||||||
|
expect(s.canConfirm, isTrue);
|
||||||
|
expect(s.dateRange!.duration, Duration.zero);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a third tap restarts from that day', () {
|
||||||
|
final s = _range()
|
||||||
|
..tap(DateTime(2026, 3, 2))
|
||||||
|
..tap(DateTime(2026, 3, 9))
|
||||||
|
..tap(DateTime(2026, 4));
|
||||||
|
expect(s.start, DateTime(2026, 4));
|
||||||
|
expect(s.end, isNull);
|
||||||
|
expect(s.canConfirm, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a preset range counts as both taps', () {
|
||||||
|
final s = _range(
|
||||||
|
DateTimeRange(start: DateTime(2026, 2, 2), end: DateTime(2026, 2, 8)),
|
||||||
|
);
|
||||||
|
expect(s.canConfirm, isTrue);
|
||||||
|
expect((s.year, s.month), (2026, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating never moves the chosen ends', () {
|
||||||
|
final s = _range()
|
||||||
|
..tap(DateTime(2026, 3, 31))
|
||||||
|
..tap(DateTime(2026, 4, 2))
|
||||||
|
..showMonth(2)
|
||||||
|
..showYear(2026)
|
||||||
|
..moveCursor(3);
|
||||||
|
expect(s.start, DateTime(2026, 3, 31));
|
||||||
|
expect(s.end, DateTime(2026, 4, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with no preset the cursor starts on today, clamped', () {
|
||||||
|
final s = _range();
|
||||||
|
expect(s.start, isNull);
|
||||||
|
expect(s.allows(s.cursor), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
183
app/test/pickers/no_words_pickers_test.dart
Normal file
183
app/test/pickers/no_words_pickers_test.dart
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
// Guard: every date, date-range and time picker the app opens paints no
|
||||||
|
// words -- only numerals and punctuation. Each picker is opened through its
|
||||||
|
// real call site, so a call site that slips back to a framework picker (which
|
||||||
|
// paints "Select date", "Cancel", "OK") turns this red.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/screens/log_meal_screen.dart';
|
||||||
|
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||||
|
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||||
|
import 'package:diet_guard_app/services/document_store_io.dart';
|
||||||
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||||
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||||
|
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/body/dated_value_card.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/body/profile_card.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/history/history_filter.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/history/history_filter_sheet.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/meal_time_dropdown.dart';
|
||||||
|
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';
|
||||||
|
|
||||||
|
Future<void> _host(WidgetTester tester, Widget child) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Scaffold(body: child)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the picker behind [control] and asserts the open dialog paints at
|
||||||
|
/// least [minStrings] strings, none of them a word, and no tooltip.
|
||||||
|
Future<void> _expectWordless(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder control, {
|
||||||
|
required int minStrings,
|
||||||
|
required String where,
|
||||||
|
}) async {
|
||||||
|
await tester.tap(control);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(Dialog), findsOneWidget, reason: '$where: no dialog');
|
||||||
|
expectNoWords(tester, minStrings: minStrings, where: where);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: find.byType(Dialog), matching: find.byType(Tooltip)),
|
||||||
|
findsNothing,
|
||||||
|
reason: '$where: a tooltip paints its words on long-press',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterSheet _sheet(DateTimeRange? range) => FilterSheet(
|
||||||
|
filter: HistoryFilter(dateRange: range),
|
||||||
|
sortField: HistorySortField.date,
|
||||||
|
ascending: false,
|
||||||
|
maxKcal: 1000,
|
||||||
|
maxProtein: 100,
|
||||||
|
maxCarbs: 100,
|
||||||
|
maxFat: 100,
|
||||||
|
onFilterChanged: (_) {},
|
||||||
|
onSortChanged: ({required field, required asc}) {},
|
||||||
|
onApply: () {},
|
||||||
|
onClear: () {},
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('meal-time exact picker', (tester) async {
|
||||||
|
await _host(
|
||||||
|
tester,
|
||||||
|
MealTimeDropdown(
|
||||||
|
icon: Icons.wb_sunny,
|
||||||
|
semanticLabel: 'First meal',
|
||||||
|
value: 480,
|
||||||
|
minMinute: 0,
|
||||||
|
maxMinute: 1424,
|
||||||
|
onChanged: (_) {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _expectWordless(
|
||||||
|
tester,
|
||||||
|
byIconLabel('Type or pick an exact time'),
|
||||||
|
minStrings: 2,
|
||||||
|
where: 'time',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final range in [
|
||||||
|
null,
|
||||||
|
DateTimeRange(start: DateTime(2026, 3, 2), end: DateTime(2026, 3, 9)),
|
||||||
|
]) {
|
||||||
|
testWidgets('history date-range picker (preset: ${range != null})', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _host(tester, _sheet(range));
|
||||||
|
await _expectWordless(
|
||||||
|
tester,
|
||||||
|
find.byIcon(Icons.date_range),
|
||||||
|
minStrings: 28,
|
||||||
|
where: 'range',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('body dated-value day picker', (tester) async {
|
||||||
|
await _host(
|
||||||
|
tester,
|
||||||
|
DatedValueCard(
|
||||||
|
semanticLabel: 'Weight',
|
||||||
|
fieldSemanticLabel: 'Weight (kg)',
|
||||||
|
icon: Icons.monitor_weight,
|
||||||
|
unit: 'kg',
|
||||||
|
min: 20,
|
||||||
|
max: 400,
|
||||||
|
latest: null,
|
||||||
|
series: const [],
|
||||||
|
onSave: (_, _) async {},
|
||||||
|
onDelete: (_) async {},
|
||||||
|
onChanged: () {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _expectWordless(
|
||||||
|
tester,
|
||||||
|
find.byIcon(Icons.event),
|
||||||
|
minStrings: 28,
|
||||||
|
where: 'dated value',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('profile birth-date picker', (tester) async {
|
||||||
|
final store = MemoryDocStore();
|
||||||
|
await setUpBodyServices(store);
|
||||||
|
addTearDown(tearDownBodyServices);
|
||||||
|
await _host(tester, ProfileCard(onSaved: () {}));
|
||||||
|
await _expectWordless(
|
||||||
|
tester,
|
||||||
|
byIconLabel('Birth date'),
|
||||||
|
minStrings: 12,
|
||||||
|
where: 'birth',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('log meal', () {
|
||||||
|
late Directory dir;
|
||||||
|
setUp(() async {
|
||||||
|
dir = await Directory.systemTemp.createTemp('dg_pickers_');
|
||||||
|
LogStorageService.resetForTesting(store: FileDocumentStore(dir));
|
||||||
|
FoodBankService.resetForTesting(store: FileDocumentStore(dir));
|
||||||
|
AppSettingsService.resetForTesting(store: FileDocumentStore(dir));
|
||||||
|
BudgetHistoryService.resetForTesting(store: FileDocumentStore(dir));
|
||||||
|
await MealScheduleService.initForTesting(FileDocumentStore(dir));
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
LogStorageService.resetForTesting();
|
||||||
|
FoodBankService.resetForTesting();
|
||||||
|
AppSettingsService.resetForTesting();
|
||||||
|
BudgetHistoryService.resetForTesting();
|
||||||
|
MealScheduleService.resetForTesting();
|
||||||
|
await dir.delete(recursive: true);
|
||||||
|
});
|
||||||
|
_logLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logLater() {
|
||||||
|
testWidgets('log-for-later date picker', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
await _expectWordless(
|
||||||
|
tester,
|
||||||
|
controlByLabel('Log for later'),
|
||||||
|
minStrings: 28,
|
||||||
|
where: 'log later',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
73
app/test/pickers/picker_call_sites_test.dart
Normal file
73
app/test/pickers/picker_call_sites_test.dart
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// The Body screen's two date controls apply what their picker confirms:
|
||||||
|
// the dated-value card's day, and the profile's birth date (whose picker
|
||||||
|
// opens with the year wheel focused).
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/models/local_time.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/body/dated_value_card.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/body/profile_card.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../label_finders.dart';
|
||||||
|
import '../screens/body_screen_support.dart';
|
||||||
|
|
||||||
|
Future<void> _host(WidgetTester tester, Widget child) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Scaffold(body: child)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pressAndSettle(
|
||||||
|
WidgetTester tester,
|
||||||
|
LogicalKeyboardKey key,
|
||||||
|
) async {
|
||||||
|
await tester.sendKeyEvent(key);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('the dated-value card shows the confirmed day', (tester) async {
|
||||||
|
await _host(
|
||||||
|
tester,
|
||||||
|
DatedValueCard(
|
||||||
|
semanticLabel: 'Weight',
|
||||||
|
fieldSemanticLabel: 'Weight (kg)',
|
||||||
|
icon: Icons.monitor_weight,
|
||||||
|
unit: 'kg',
|
||||||
|
min: 20,
|
||||||
|
max: 400,
|
||||||
|
latest: null,
|
||||||
|
series: const [],
|
||||||
|
onSave: (_, _) async {},
|
||||||
|
onDelete: (_) async {},
|
||||||
|
onChanged: () {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final yesterday = localDateKey(DateTime(now.year, now.month, now.day - 1));
|
||||||
|
await tester.tap(find.byIcon(Icons.event));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await _pressAndSettle(tester, LogicalKeyboardKey.arrowLeft);
|
||||||
|
await _pressAndSettle(tester, LogicalKeyboardKey.enter);
|
||||||
|
expect(find.byType(Dialog), findsNothing);
|
||||||
|
expect(find.text(' $yesterday'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the birth date opens on the year wheel and applies', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await setUpBodyServices(MemoryDocStore());
|
||||||
|
addTearDown(tearDownBodyServices);
|
||||||
|
await _host(tester, ProfileCard(onSaved: () {}));
|
||||||
|
final year = DateTime.now().year - 31;
|
||||||
|
await tester.tap(byIconLabel('Birth date'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await _pressAndSettle(tester, LogicalKeyboardKey.arrowUp);
|
||||||
|
await _pressAndSettle(tester, LogicalKeyboardKey.enter);
|
||||||
|
expect(find.byType(Dialog), findsNothing);
|
||||||
|
expect(find.text(' $year-01-01'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
84
app/test/pickers/picker_test_support.dart
Normal file
84
app/test/pickers/picker_test_support.dart
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// Shared helpers for the picker widget tests: open a dialog from a button and
|
||||||
|
// read back what it popped, and find cells by state rather than by text.
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/numeral_cell.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../label_finders.dart';
|
||||||
|
|
||||||
|
/// What a dialog popped: [closed] turns true once it is gone.
|
||||||
|
class Popped {
|
||||||
|
/// The popped value (null when cancelled).
|
||||||
|
Object? value;
|
||||||
|
|
||||||
|
/// Whether the dialog has closed.
|
||||||
|
bool closed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pumps an app whose one button opens [dialog]; taps it and settles.
|
||||||
|
Future<Popped> openDialog(
|
||||||
|
WidgetTester tester,
|
||||||
|
Widget dialog, {
|
||||||
|
Size size = const Size(500, 900),
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final popped = Popped();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => IconButton(
|
||||||
|
icon: const Icon(Icons.add, semanticLabel: 'open'),
|
||||||
|
onPressed: () async {
|
||||||
|
popped
|
||||||
|
..value = await showDialog<Object>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => dialog,
|
||||||
|
)
|
||||||
|
..closed = true;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(byIconLabel('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return popped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cell keyed [key] (a `DateTime` day, or `'month-<m>'`).
|
||||||
|
NumeralCell cell(WidgetTester tester, Object key) => tester.widget<NumeralCell>(
|
||||||
|
find.byKey(
|
||||||
|
key is DateTime ? ValueKey<DateTime>(key) : ValueKey<String>('$key'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The check button, enabled or not.
|
||||||
|
IconButton confirmButton(WidgetTester tester) => tester.widget<IconButton>(
|
||||||
|
find.ancestor(of: byIconLabel('Confirm'), matching: find.byType(IconButton)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Scrolls the open time dialog's wheels to [hour] and [minute] (a disabled
|
||||||
|
/// value springs back) and settles. Does not confirm.
|
||||||
|
Future<void> setWheels(WidgetTester tester, int hour, int minute) async {
|
||||||
|
for (final (key, value) in [('hour', hour), ('minute', minute)]) {
|
||||||
|
final wheel = tester.widget<ListWheelScrollView>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(ValueKey(key)),
|
||||||
|
matching: find.byType(ListWheelScrollView),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
(wheel.controller! as FixedExtentScrollController).jumpToItem(value);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Taps the check and settles.
|
||||||
|
Future<void> confirm(WidgetTester tester) async {
|
||||||
|
await tester.tap(controlByLabel('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
156
app/test/pickers/time_dialog_test.dart
Normal file
156
app/test/pickers/time_dialog_test.dart
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// The time dialog: two looping 24 h wheels, bounds dimmed and inert,
|
||||||
|
// keyboard, mouse, confirm vs cancel.
|
||||||
|
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/time_dialog.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/pickers/time_wheels.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../label_finders.dart';
|
||||||
|
import 'picker_test_support.dart';
|
||||||
|
|
||||||
|
Future<Popped> _open(
|
||||||
|
WidgetTester tester, {
|
||||||
|
int initial = 8 * 60,
|
||||||
|
int min = 0,
|
||||||
|
int max = 1439,
|
||||||
|
}) => openDialog(
|
||||||
|
tester,
|
||||||
|
TimeDialog(initialMinute: initial, minMinute: min, maxMinute: max),
|
||||||
|
);
|
||||||
|
|
||||||
|
Finder _wheel(String which) => find.byKey(ValueKey(which));
|
||||||
|
|
||||||
|
/// Moves keyboard focus to the minute wheel (the hour wheel has it first).
|
||||||
|
Future<void> _focusMinutes(WidgetTester tester) async {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeOfDay _value(Popped p) => p.value! as TimeOfDay;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('hourAllowed and clampMinute', () {
|
||||||
|
expect(hourAllowed(7, 480, 1200), isFalse);
|
||||||
|
expect(hourAllowed(8, 495, 1200), isTrue);
|
||||||
|
expect(hourAllowed(20, 480, 1200), isTrue);
|
||||||
|
expect(hourAllowed(21, 480, 1200), isFalse);
|
||||||
|
expect(clampMinute(10, 480, 1200), 480);
|
||||||
|
expect(clampMinute(1300, 480, 1200), 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('wheels show 24 h numerals and confirm the centre', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final popped = await _open(tester, initial: 13 * 60 + 5);
|
||||||
|
expect(find.byKey(const ValueKey('wheel-13')), findsWidgets);
|
||||||
|
expect(find.text('13'), findsWidgets);
|
||||||
|
expect(find.text('05'), findsWidgets);
|
||||||
|
await confirm(tester);
|
||||||
|
expect(_value(popped), const TimeOfDay(hour: 13, minute: 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('arrows loop: hour 23 -> 00, minute 59 -> 00', (tester) async {
|
||||||
|
final popped = await _open(tester, initial: 23 * 60 + 59);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await _focusMinutes(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_value(popped), const TimeOfDay(hour: 0, minute: 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('mouse drag scrolls a wheel; a click selects a number', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final popped = await _open(tester, initial: 10 * 60);
|
||||||
|
await tester.drag(
|
||||||
|
_wheel('hour'),
|
||||||
|
const Offset(0, -85),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('wheel-58')),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await confirm(tester);
|
||||||
|
expect(_value(popped), const TimeOfDay(hour: 12, minute: 58));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('out-of-range values are inert; an hour change clamps the '
|
||||||
|
'minute into range', (tester) async {
|
||||||
|
final popped = await _open(tester, initial: 9 * 60 + 5, min: 8 * 60 + 15);
|
||||||
|
// 07 is dimmed: a tap does nothing.
|
||||||
|
await tester.tap(find.byKey(const ValueKey('wheel-7')).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await confirm(tester);
|
||||||
|
expect(
|
||||||
|
_value(popped),
|
||||||
|
const TimeOfDay(hour: 8, minute: 15),
|
||||||
|
reason: '09:05 -> 08 pulls :05 up to :15; 07 refused',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a wheel dragged onto a dimmed value springs back', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final popped = await _open(tester, initial: 20 * 60, max: 20 * 60);
|
||||||
|
await tester.drag(_wheel('hour'), const Offset(0, -85));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await confirm(tester);
|
||||||
|
expect(_value(popped), const TimeOfDay(hour: 20, minute: 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an initial value outside the range opens clamped', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final popped = await _open(tester, initial: 6 * 60, min: 8 * 60 + 15);
|
||||||
|
await confirm(tester);
|
||||||
|
expect(_value(popped), const TimeOfDay(hour: 8, minute: 15));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final how in ['close icon', 'outside tap', 'Esc']) {
|
||||||
|
testWidgets('$how cancels', (tester) async {
|
||||||
|
final popped = await _open(tester);
|
||||||
|
switch (how) {
|
||||||
|
case 'close icon':
|
||||||
|
await tester.tap(controlByLabel('Cancel'));
|
||||||
|
case 'outside tap':
|
||||||
|
await tester.tapAt(const Offset(4, 4));
|
||||||
|
default:
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
}
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(popped.closed, isTrue);
|
||||||
|
expect(popped.value, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('Enter on the focused close icon cancels', (tester) async {
|
||||||
|
final popped = await _open(tester);
|
||||||
|
await _focusMinutes(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(popped.closed, isTrue);
|
||||||
|
expect(popped.value, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('wheels speak; no tooltip', (tester) async {
|
||||||
|
await _open(tester);
|
||||||
|
expect(bySemLabel('Hour'), findsOneWidget);
|
||||||
|
expect(bySemLabel('Minute'), findsOneWidget);
|
||||||
|
expect(find.byType(Tooltip), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -125,6 +125,25 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('pickers paint no words', () {
|
||||||
|
// The framework pickers paint "Select date", "Cancel", "OK", weekday and
|
||||||
|
// month names and tooltips; lib/widgets/pickers/ replaces all three.
|
||||||
|
test('no showDatePicker, showDateRangePicker or showTimePicker', () {
|
||||||
|
final banned = RegExp(r'show(Date|DateRange|Time)Picker\(');
|
||||||
|
final offenders = [
|
||||||
|
for (final file in _dartFilesIn('lib'))
|
||||||
|
if (banned.hasMatch(file.readAsStringSync()))
|
||||||
|
file.path.split('/app/lib/').last,
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
isEmpty,
|
||||||
|
reason: 'A framework picker came back. Use pickDate, pickDateRange '
|
||||||
|
'or pickTime from widgets/pickers/pickers.dart.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('singletons that silently no-op are initialised at startup', () {
|
group('singletons that silently no-op are initialised at startup', () {
|
||||||
// Every catering entry point checks `isInitialized` first and returns
|
// Every catering entry point checks `isInitialized` first and returns
|
||||||
// quietly when it is false, so a missing `init()` does not throw -- it
|
// quietly when it is false, so a missing `init()` does not throw -- it
|
||||||
|
|||||||
@ -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_energy.dart';
|
||||||
import 'package:diet_guard_app/services/body_prefs.dart';
|
import 'package:diet_guard_app/services/body_prefs.dart';
|
||||||
import 'package:diet_guard_app/services/body_service.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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../label_finders.dart';
|
||||||
import 'body_screen_support.dart';
|
import 'body_screen_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@ -22,10 +24,12 @@ void main() {
|
|||||||
await seedPerson(store);
|
await seedPerson(store);
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
await openTile(tester, 'Daily calorie target');
|
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 {
|
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.pumpAndSettle();
|
||||||
await tester.tap(find.text(option).last);
|
await tester.tap(bySemLabel(option).last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,24 +54,25 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// A typed amount; junk is refused and the dialog stays open.
|
// 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.tap(find.text('1'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.enterText(other, '');
|
await tester.enterText(other, '');
|
||||||
await tester.tap(find.text('OK'));
|
await tester.tap(byIconLabel('OK'));
|
||||||
await tester.pumpAndSettle();
|
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.enterText(other, '2,5');
|
||||||
await tester.tap(find.text('OK'));
|
await tester.tap(byIconLabel('OK'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(savedGoal(BodyService.instance).amount, 2.5);
|
expect(savedGoal(BodyService.instance).amount, 2.5);
|
||||||
|
|
||||||
// Cancel changes nothing; neither does dismissing a sheet.
|
// Cancel changes nothing; neither does dismissing a sheet.
|
||||||
await tester.tap(find.text('2.5'));
|
await tester.tap(find.text('2.5'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Cancel'));
|
await tester.tap(byIconLabel('Cancel'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('gain'));
|
await tester.tap(bySemLabel('gain').first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tapAt(const Offset(5, 5));
|
await tester.tapAt(const Offset(5, 5));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@ -86,16 +91,19 @@ void main() {
|
|||||||
await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET'));
|
await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET'));
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
await openTile(tester, 'Daily calorie target');
|
await openTile(tester, 'Daily calorie target');
|
||||||
expect(find.textContaining('no workouts published yet'), findsOneWidget);
|
expect(
|
||||||
await tester.tap(find.text('Measured (MET)'));
|
cueLabelled('no workouts published yet (the PC publishes them).'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tester.tap(bySemLabel('Measured (MET)').first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Sedentary').last);
|
await tester.tap(bySemLabel('Sedentary').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('1'));
|
await tester.tap(find.text('1'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Other amount'), '3');
|
await tester.enterText(fieldByLabel('Other amount'), '3');
|
||||||
await tester.tap(find.text('OK'));
|
await tester.tap(byIconLabel('OK'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.textContaining('below the 1200 kcal floor'), findsOneWidget);
|
expect(cueLabelled('That is below the 1200 kcal floor.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import 'package:diet_guard_app/models/local_time.dart';
|
import 'package:diet_guard_app/models/local_time.dart';
|
||||||
import 'package:diet_guard_app/services/body_service.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/health_steps.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.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 '../services/health_steps_test.dart' show FakeStepsSource;
|
||||||
|
import 'body_screen_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final original = stepsSource;
|
final original = stepsSource;
|
||||||
@ -36,14 +36,14 @@ void main() {
|
|||||||
expect(fake.lastRead, isNull, reason: 'never reads before permission');
|
expect(fake.lastRead, isNull, reason: 'never reads before permission');
|
||||||
await openTile(tester, 'Activity');
|
await openTile(tester, 'Activity');
|
||||||
|
|
||||||
await tester.tap(find.text('Connect Health Connect steps'));
|
await tester.tap(byIconLabel('Connect Health Connect steps'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Connect Health Connect steps'), findsOneWidget);
|
expect(byIconLabel('Connect Health Connect steps'), findsOneWidget);
|
||||||
|
|
||||||
fake.grantOnRequest = true;
|
fake.grantOnRequest = true;
|
||||||
await tester.tap(find.text('Connect Health Connect steps'));
|
await tester.tap(byIconLabel('Connect Health Connect steps'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Connect Health Connect steps'), findsNothing);
|
expect(byIconLabel('Connect Health Connect steps'), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
BodyService.instance.document.steps[localDateKey(yesterday)]!.n,
|
BodyService.instance.document.steps[localDateKey(yesterday)]!.n,
|
||||||
6500,
|
6500,
|
||||||
@ -56,13 +56,13 @@ void main() {
|
|||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
await openTile(tester, 'Activity');
|
await openTile(tester, 'Activity');
|
||||||
expect(fake.lastRead, isNotNull);
|
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 {
|
testWidgets('a Health Connect error just hides nothing', (tester) async {
|
||||||
stepsSource = FakeStepsSource()..failWith = StateError('gone');
|
stepsSource = FakeStepsSource()..failWith = StateError('gone');
|
||||||
await pumpBody(tester);
|
await pumpBody(tester);
|
||||||
await openTile(tester, 'Activity');
|
await openTile(tester, 'Activity');
|
||||||
expect(find.text('Connect Health Connect steps'), findsOneWidget);
|
expect(byIconLabel('Connect Health Connect steps'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user