mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 12:27:42 +01:00
Icons for formulas, fit tile value, channel name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
This commit is contained in:
parent
445712dea3
commit
60ce1a6d71
@ -15,7 +15,8 @@ class LocalNotificationsBackend implements NotificationBackend {
|
||||
final FlutterLocalNotificationsPlugin plugin;
|
||||
|
||||
static const _channelId = 'diet_guard_due_slot';
|
||||
static const _channelName = 'Meal reminders';
|
||||
// The app's proper name, same as the launcher label (no descriptive text).
|
||||
static const _channelName = 'DietGuard';
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:diet_guard_app/ui/cues.dart';
|
||||
import 'package:diet_guard_app/ui/macro_kind.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_section.dart';
|
||||
import 'package:diet_guard_app/widgets/body/formula_figure.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// How many of the newest sessions to list.
|
||||
@ -145,7 +146,7 @@ class ActivityCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula's proper name on the left, its kcal figure on the right.
|
||||
/// A formula's glyph on the left, its kcal figure on the right.
|
||||
class _FormulaRow extends StatelessWidget {
|
||||
const _FormulaRow(this.name, this.value);
|
||||
|
||||
@ -157,7 +158,12 @@ class _FormulaRow extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FormulaGlyph(name),
|
||||
),
|
||||
),
|
||||
MacroFigure(kind: MacroKind.kcal, value: value, size: 16),
|
||||
],
|
||||
),
|
||||
@ -174,10 +180,6 @@ class _SessionTile extends StatelessWidget {
|
||||
final session = result.session;
|
||||
final km = session.km;
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final kcal = [
|
||||
for (final name in activityFormulas)
|
||||
_formulaKcal(name, result.kcal[name]!, result.fallback.contains(name)),
|
||||
].join(' · ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
child: Column(
|
||||
@ -205,7 +207,20 @@ class _SessionTile extends StatelessWidget {
|
||||
color: MacroKind.kcal.color,
|
||||
semanticLabel: 'kcal',
|
||||
),
|
||||
Expanded(child: Text(' $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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -213,6 +228,3 @@ class _SessionTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formulaKcal(String name, double kcal, bool fallback) =>
|
||||
'$name ${whole(kcal)}${fallback ? '~' : ''}';
|
||||
|
||||
@ -109,20 +109,35 @@ class _Tile extends StatelessWidget {
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (valueIcon != null)
|
||||
Icon(valueIcon, size: 18, color: theme.colorScheme.primary),
|
||||
if (value != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
66
app/lib/widgets/body/formula_figure.dart
Normal file
66
app/lib/widgets/body/formula_figure.dart
Normal file
@ -0,0 +1,66 @@
|
||||
/// A kcal formula shown as a glyph + number; its proper name is only a
|
||||
/// long-press tooltip.
|
||||
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], tooltipped with 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;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Tooltip(
|
||||
message: name,
|
||||
child: Icon(
|
||||
formulaIcons[name],
|
||||
size: size,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.144+1
|
||||
version: 1.0.145+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
141
app/test/widgets/body/formula_and_tile_layout_test.dart
Normal file
141
app/test/widgets/body/formula_and_tile_layout_test.dart
Normal file
@ -0,0 +1,141 @@
|
||||
import 'package:diet_guard_app/models/workout_session.dart';
|
||||
import 'package:diet_guard_app/widgets/body/activity_card.dart';
|
||||
import 'package:diet_guard_app/widgets/body/body_tile_grid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Future<void> _pumpAt(WidgetTester tester, Size dp, Widget child) async {
|
||||
tester.view.physicalSize = dp;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
const _run = Session(
|
||||
day: '2026-09-27',
|
||||
kind: 'run',
|
||||
minutes: 30,
|
||||
km: 5,
|
||||
label: 'Morning run',
|
||||
source: 'pc',
|
||||
);
|
||||
|
||||
bool _paintsFormulaWord(Widget w) =>
|
||||
w is Text &&
|
||||
const [
|
||||
'MET',
|
||||
'ACSM',
|
||||
'Per-km',
|
||||
].any((word) => (w.data ?? '').contains(word));
|
||||
|
||||
void main() {
|
||||
testWidgets('activity formulas are icons; names only in tooltips', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpAt(
|
||||
tester,
|
||||
const Size(360, 2400),
|
||||
const ActivityCard(
|
||||
sessions: [_run],
|
||||
weightKg: 70,
|
||||
exercise: {'MET': 300, 'ACSM': 310, 'Per-km': 320},
|
||||
),
|
||||
);
|
||||
expect(find.byWidgetPredicate(_paintsFormulaWord), findsNothing);
|
||||
for (final name in ['MET', 'ACSM', 'Per-km']) {
|
||||
// One in the mean row, one in the session tile.
|
||||
expect(find.byTooltip(name), findsNWidgets(2));
|
||||
}
|
||||
expect(find.text('300'), findsOneWidget);
|
||||
expect(find.text('310'), findsOneWidget);
|
||||
expect(find.text('320'), findsOneWidget);
|
||||
// Distinct glyphs, so the three are told apart without words.
|
||||
final icons = tester
|
||||
.widgetList<Icon>(
|
||||
find.descendant(
|
||||
of: find.byTooltip('MET').first,
|
||||
matching: find.byType(Icon),
|
||||
),
|
||||
)
|
||||
.map((i) => i.icon)
|
||||
.toSet();
|
||||
expect(icons, hasLength(1));
|
||||
final glyphs = {
|
||||
for (final name in ['MET', 'ACSM', 'Per-km'])
|
||||
(tester
|
||||
.widget<Icon>(
|
||||
find.descendant(
|
||||
of: find.byTooltip(name).first,
|
||||
matching: find.byType(Icon),
|
||||
),
|
||||
)
|
||||
.icon),
|
||||
};
|
||||
expect(glyphs, hasLength(3));
|
||||
});
|
||||
|
||||
testWidgets('a fallback formula keeps its tilde marker', (tester) async {
|
||||
const lift = Session(
|
||||
day: '2026-09-27',
|
||||
kind: 'lift',
|
||||
minutes: 60,
|
||||
km: null,
|
||||
label: 'Lift',
|
||||
source: 'pc',
|
||||
);
|
||||
await _pumpAt(
|
||||
tester,
|
||||
const Size(360, 2400),
|
||||
const ActivityCard(sessions: [lift], weightKg: 70, exercise: null),
|
||||
);
|
||||
expect(find.textContaining('~'), findsNWidgets(2));
|
||||
expect(find.byWidgetPredicate(_paintsFormulaWord), findsNothing);
|
||||
});
|
||||
|
||||
for (final width in [360.0, 411.0]) {
|
||||
testWidgets('a weight tile value fits whole at $width dp', (tester) async {
|
||||
await _pumpAt(
|
||||
tester,
|
||||
Size(width, 800),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: BodyTileGrid(
|
||||
tiles: [
|
||||
BodyTileSpec(
|
||||
semanticLabel: 'Weight',
|
||||
icon: Icons.monitor_weight,
|
||||
value: '70.0 kg',
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
BodyTileSpec(
|
||||
semanticLabel: 'Body fat',
|
||||
icon: Icons.percent,
|
||||
value: '24.5 %',
|
||||
valueIcon: Icons.cake,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final value in ['70.0 kg', '24.5 %']) {
|
||||
final text = find.text(value);
|
||||
final paragraph = tester.renderObject<RenderParagraph>(text);
|
||||
expect(paragraph.didExceedMaxLines, isFalse, reason: value);
|
||||
final card = find.ancestor(of: text, matching: find.byType(Card));
|
||||
final inside = tester.getRect(card).inflate(0.5);
|
||||
expect(inside.contains(tester.getRect(text).topLeft), isTrue);
|
||||
expect(inside.contains(tester.getRect(text).bottomRight), isTrue);
|
||||
// The full text is laid out (no ellipsis clipping): its width is
|
||||
// the natural width of "70.0 kg" at this font.
|
||||
expect(tester.getSize(text).width, greaterThan(40));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user