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:
Krzysztof Rudnicki 2026-11-09 10:04:42 +01:00
parent 445712dea3
commit 60ce1a6d71
6 changed files with 258 additions and 23 deletions

View File

@ -15,7 +15,8 @@ class LocalNotificationsBackend implements NotificationBackend {
final FlutterLocalNotificationsPlugin plugin;
static const _channelId = 'diet_guard_due_slot';
static const _channelName = 'Meal reminders';
// The app's proper name, same as the launcher label (no descriptive text).
static const _channelName = 'DietGuard';
@override
Future<void> initialize() async {

View File

@ -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 ? '~' : ''}';

View File

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

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

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.144+1
version: 1.0.145+1
environment:
sdk: ^3.12.2

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