Make history screen text-free

Section headings and the all-workouts entry are glyphs, tiles drop
their word prefixes, chart and calendar dates are numeric, weekday
headers are dots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 01:27:59 +01:00
parent 661b293bc2
commit 5fa160f114
12 changed files with 200 additions and 129 deletions

View File

@ -173,12 +173,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
_syncedRows.isEmpty &&
_plannedRows.isEmpty &&
_runs.isEmpty
? Center(
child: Text(
'No workouts yet.',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
)
? const _EmptyHistory()
: ListView(
padding: const EdgeInsets.all(12),
children: [
@ -210,18 +205,21 @@ class _HistoryScreenState extends State<HistoryScreen> {
_RunsStrip(runs: _runs, onOpen: _openRun),
const SizedBox(height: 16),
],
const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'),
const _SectionLabel(
Icons.stacked_line_chart,
'TOTAL VOLUME (2-session rolling avg, kg)',
),
const SizedBox(height: 6),
_WeightChart(points: _rollingAvg2(_totalVolumePoints(_rows))),
const SizedBox(height: 16),
_calendar(_allWorkoutDates(_rows, _syncedRows, _runs)),
const SizedBox(height: 16),
const _SectionLabel('ALL SESSIONS'),
const _SectionLabel(Icons.list, 'ALL SESSIONS'),
const SizedBox(height: 8),
..._rows.map((row) => _AllSessionTile(row: row)),
if (_syncedRows.isNotEmpty) ...[
const SizedBox(height: 16),
const _SectionLabel('SYNCED FROM PC'),
const _SectionLabel(Icons.computer, 'SYNCED FROM PC'),
const SizedBox(height: 8),
..._syncedRows.map((row) => _SyncedWorkoutTile(payload: row)),
],
@ -232,13 +230,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
_ProgressStatsCard(state: _selectedState!),
const SizedBox(height: 12),
],
const _SectionLabel('WEIGHT OVER TIME'),
const _SectionLabel(Icons.show_chart, 'WEIGHT OVER TIME'),
const SizedBox(height: 6),
_WeightChart(points: _exerciseWeightPoints(_rows, name)),
const SizedBox(height: 16),
_calendar(_exerciseDates(_rows, name)),
const SizedBox(height: 16),
_SectionLabel(name.toUpperCase()),
// The picker above already names the exercise: a list glyph here.
_SectionLabel(Icons.list, name),
const SizedBox(height: 8),
..._sessionsForExercise(
_rows,

View File

@ -7,24 +7,44 @@ part of 'history_screen.dart';
// ── Shared sub-widgets ──────────────────────────────────────────────────────
/// A section heading as a glyph (textgate: was an all-caps word heading;
/// the words survive as [spoken], the glyph's semantic label).
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
const _SectionLabel(this.icon, this.spoken);
final String text;
final IconData icon;
final String spoken;
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(
return Align(
alignment: Alignment.centerLeft,
child: Icon(
icon,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
letterSpacing: 1.3,
semanticLabel: spoken,
),
);
}
}
/// The empty history: a large muted history glyph (textgate: was "No
/// workouts yet.").
class _EmptyHistory extends StatelessWidget {
const _EmptyHistory();
@override
Widget build(BuildContext context) => Center(
child: Icon(
Icons.history,
size: 48,
color: Theme.of(context).colorScheme.outlineVariant,
semanticLabel: 'No workouts yet.',
),
);
}
class _ExercisePicker extends StatelessWidget {
const _ExercisePicker({
required this.names,
@ -49,15 +69,18 @@ class _ExercisePicker extends StatelessWidget {
.map(
(n) => DropdownMenuItem(
value: n,
child: Text(
n,
style: TextStyle(
color: n == _kTotal
? colorScheme.onSurfaceVariant
: colorScheme.onSurface,
fontStyle: n == _kTotal ? FontStyle.italic : FontStyle.normal,
),
),
// textgate: the all-workouts entry is a sum glyph (was
// "Total (all workouts)"); exercise names are content.
child: n == _kTotal
? Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.functions,
color: colorScheme.onSurfaceVariant,
semanticLabel: _kTotal,
),
)
: Text(n, style: TextStyle(color: colorScheme.onSurface)),
),
)
.toList(),
@ -162,7 +185,8 @@ class _StreakRow extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'${threshold - shown} more to $direction $actionLabel',
// textgate: was "2 more to ↑ 25 kg".
'${threshold - shown} $direction $actionLabel',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
@ -187,9 +211,11 @@ class _WeightChart extends StatelessWidget {
return Container(
height: 80,
alignment: Alignment.center,
child: Text(
'Not enough data for chart',
style: TextStyle(color: colorScheme.onSurfaceVariant),
// textgate: was "Not enough data for chart".
child: Icon(
Icons.show_chart,
color: colorScheme.outlineVariant,
semanticLabel: 'Not enough data for chart',
),
);
}

View File

@ -5,7 +5,6 @@
// these are `part` files.
part of 'history_screen.dart';
class _ChartPainter extends CustomPainter {
_ChartPainter(
this.points, {
@ -25,22 +24,8 @@ class _ChartPainter extends CustomPainter {
static const _bottomPad = 22.0; // room for X-axis dates
static const _hPad = 8.0;
static const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
static String _shortDate(DateTime d) => '${_months[d.month - 1]} ${d.day}';
// textgate: numeric day.month on the axis (was "Aug 15").
static String _shortDate(DateTime d) => formatShortDate(d);
@override
void paint(Canvas canvas, Size size) {

View File

@ -17,7 +17,7 @@ extension _HistoryPlanned on _HistoryScreenState {
/// when there are none.
List<Widget> _plannedSection() => [
if (_plannedRows.isNotEmpty) ...[
const _SectionLabel('PLANNED (not done yet)'),
const _SectionLabel(Icons.event, 'PLANNED (not done yet)'),
const SizedBox(height: 8),
..._plannedRows.map(
(row) => _PlannedWorkoutTile(
@ -153,7 +153,9 @@ class _PlannedWorkoutTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${payload['date']} $time · Planned',
// textgate: was "<date> <time> · Planned"; the event
// glyph and the section glyph already say planned.
'${payload['date']} $time',
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,

View File

@ -13,10 +13,12 @@ class _SyncedWorkoutTile extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final kind = '${payload['kind']}';
final isRun = kind == 'runnerup_verified';
final label = isRun ? 'Run' : 'Manual';
final semanticLabel = isRun ? 'Run' : 'Manual';
final detail = '${payload['source'] ?? ''}';
return Container(
// Tests find a tile by date and kind; nothing paints the kind word.
key: ValueKey('synced-${payload['date']}-$semanticLabel'),
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
@ -32,14 +34,16 @@ class _SyncedWorkoutTile extends StatelessWidget {
isRun ? Icons.directions_run : Icons.edit_note,
color: colorScheme.onSurfaceVariant,
size: 20,
semanticLabel: semanticLabel,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// textgate: was "<date> · Run|Manual"; the glyph says which.
Text(
'${payload['date']} · $label',
'${payload['date']}',
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
@ -104,8 +108,9 @@ class _AllSessionTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// textgate: was "Workout A · <date>".
Text(
'Workout $type · $date',
'$type · $date',
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
@ -191,17 +196,29 @@ class _ExerciseSessionTile extends StatelessWidget {
),
),
const SizedBox(height: 2),
Text(
'${weight ?? '?'}kg · $doneSets/$targetSets sets'
' · ${warmupDone ? '⬤ warmup' : '○ no warmup'}',
style: mutedStyle,
// textgate: was "20kg · 3/3 sets · ⬤ warmup" / "reps: 5, 5"
// / "workout: 30m 00s". Now numbers behind glyphs; the warmup
// is a filled or hollow sun.
Row(
children: [
Text(
'${weight ?? '?'}kg · $doneSets/$targetSets ',
style: mutedStyle,
),
Icon(
warmupDone ? Icons.wb_sunny : Icons.wb_sunny_outlined,
size: 14,
color: mutedStyle.color,
semanticLabel: warmupDone ? 'warmup' : 'no warmup',
),
],
),
if (repsSummary.isNotEmpty) ...[
const SizedBox(height: 2),
Text('reps: $repsSummary', style: mutedStyle),
_GlyphLine(Icons.repeat, repsSummary, mutedStyle),
],
const SizedBox(height: 2),
Text('workout: $dur', style: mutedStyle),
_GlyphLine(Icons.timer, dur, mutedStyle),
],
),
),
@ -210,3 +227,22 @@ class _ExerciseSessionTile extends StatelessWidget {
);
}
}
/// A small glyph followed by a number line (textgate: replaces a word
/// prefix such as "reps:" or "workout:").
class _GlyphLine extends StatelessWidget {
const _GlyphLine(this.icon, this.text, this.style);
final IconData icon;
final String text;
final TextStyle style;
@override
Widget build(BuildContext context) => Row(
children: [
Icon(icon, size: 14, color: style.color),
const SizedBox(width: 4),
Text(text, style: style),
],
);
}

View File

@ -27,21 +27,16 @@ class WorkoutCalendar extends StatelessWidget {
/// Called when the user taps the next-month chevron.
final VoidCallback onNextMonth;
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',
// textgate: weekday headers are dots (weekend dots hollow) and the
// month header is `08.2026`; the old words are spoken only.
static const _weekdaySemanticsLabels = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
String _dateKey(int year, int m, int day) =>
@ -81,7 +76,7 @@ class WorkoutCalendar extends StatelessWidget {
onPressed: onPrevMonth,
),
Text(
'${_monthNames[m - 1]} $year',
'${m.toString().padLeft(2, '0')}.$year',
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
@ -103,22 +98,29 @@ class WorkoutCalendar extends StatelessWidget {
// Day-of-week headers
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: _weekHeaders
.map(
(h) => SizedBox(
width: 30,
child: Text(
h,
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
fontWeight: FontWeight.bold,
children: [
for (var i = 0; i < 7; i++)
SizedBox(
width: 30,
child: Semantics(
label: _weekdaySemanticsLabels[i],
child: Center(
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
// Weekend: hollow ring; weekday: filled dot.
color: i >= 5 ? null : colorScheme.onSurfaceVariant,
border: Border.all(
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
)
.toList(),
),
],
),
const SizedBox(height: 4),
// Day grid

View File

@ -138,8 +138,8 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
// Exercise view rendered: chart + streak stats card near the top.
expect(find.text('WEIGHT OVER TIME'), findsOneWidget);
expect(find.textContaining('more to'), findsWidgets);
expect(iconLabeled('WEIGHT OVER TIME'), findsOneWidget);
expect(find.textContaining('↑'), findsWidgets);
// Exercise-view calendar month nav (covers its onPrev/onNext closures).
await tester.tap(find.byIcon(Icons.chevron_right).first);
@ -149,14 +149,17 @@ void main() {
// The session list + name label sit below the fold; scroll to build them.
await tester.scrollUntilVisible(
find.text(name.toUpperCase()),
iconLabeled(name),
300,
scrollable: find.byType(Scrollable).first,
);
expect(find.text(name.toUpperCase()), findsOneWidget);
expect(iconLabeled(name), findsOneWidget);
// Session tiles: warmup marker and reps summary both present.
expect(find.textContaining('warmup'), findsWidgets);
expect(find.textContaining('reps:'), findsWidgets);
expect([
...iconLabeled('warmup').evaluate(),
...iconLabeled('no warmup').evaluate(),
], isNotEmpty);
expect(find.byIcon(Icons.repeat), findsWidgets);
});
testWidgets('chart handles two points on the same date (tRange == 0)', (
@ -181,7 +184,7 @@ void main() {
await seedSession(tester, vol(40), date: '2024-06-01');
await pumpHistory(tester, wrapHistory());
expect(find.byType(HistoryScreen), findsOneWidget);
expect(find.text('WEIGHT OVER TIME').evaluate(), isEmpty); // total view
expect(iconLabeled('WEIGHT OVER TIME').evaluate(), isEmpty); // total view
});
testWidgets('shows the PC-synced workouts the phone has no session for', (
@ -210,12 +213,15 @@ void main() {
await pumpHistory(tester, wrapHistory(httpClient: historySyncMock()));
await tester.scrollUntilVisible(
find.text('SYNCED FROM PC'),
iconLabeled('SYNCED FROM PC'),
200,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('SYNCED FROM PC'), findsOneWidget);
expect(find.textContaining('2026-07-13 · Run'), findsOneWidget);
expect(find.textContaining('2026-07-13 · Manual'), findsOneWidget);
expect(iconLabeled('SYNCED FROM PC'), findsOneWidget);
expect(find.byKey(const ValueKey('synced-2026-07-13-Run')), findsOneWidget);
expect(
find.byKey(const ValueKey('synced-2026-07-13-Manual')),
findsOneWidget,
);
});
}

View File

@ -139,14 +139,23 @@ void main() {
await _open(tester, _github(_seed()).client);
// No local sessions, but synced and planned entries still show.
expect(find.text('No workouts yet.'), findsNothing);
expect(find.text('PLANNED (not done yet)'), findsOneWidget);
expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget);
expect(iconLabeled('No workouts yet.'), findsNothing);
expect(iconLabeled('PLANNED (not done yet)'), findsOneWidget);
expect(find.text('2026-10-17 10:00'), findsOneWidget);
expect(iconLabeled('Delete planned workout'), findsOneWidget);
// Today's entry already happened: done, and not deletable.
expect(find.text('2026-10-16 · Manual'), findsOneWidget);
expect(find.text('2026-10-14 · Manual'), findsOneWidget);
expect(find.text('2026-10-18 · Manual'), findsOneWidget);
expect(
find.byKey(const ValueKey('synced-2026-10-16-Manual')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('synced-2026-10-14-Manual')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('synced-2026-10-18-Manual')),
findsOneWidget,
);
final calendar = tester.widget<WorkoutCalendar>(
find.byType(WorkoutCalendar),
);
@ -169,8 +178,8 @@ void main() {
// The tile goes only once the reload has read the tombstone back.
await _settle(tester, 4);
await tester.pumpAndSettle();
expect(find.text('PLANNED (not done yet)'), findsNothing);
expect(find.text('2026-10-17 10:00 · Planned'), findsNothing);
expect(iconLabeled('PLANNED (not done yet)'), findsNothing);
expect(find.text('2026-10-17 10:00'), findsNothing);
});
testWidgets('KEEP leaves the plan alone', (tester) async {
@ -184,7 +193,7 @@ void main() {
github.files.containsKey('screen-locker-sync/devices/phone/log.json'),
isFalse,
);
expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget);
expect(find.text('2026-10-17 10:00'), findsOneWidget);
});
testWidgets('a failed delete keeps the tile and says why', (tester) async {
@ -195,7 +204,7 @@ void main() {
expect(iconLabeled('Not deleted'), findsOneWidget);
expect(find.byIcon(Icons.cloud_off), findsOneWidget);
expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget);
expect(find.text('2026-10-17 10:00'), findsOneWidget);
});
testWidgets('a delete confirmed once the day has started is refused', (
@ -220,6 +229,9 @@ void main() {
);
// Reloaded against the new day: it is done now, not planned.
await tester.pumpAndSettle();
expect(find.text('2026-10-17 · Manual'), findsOneWidget);
expect(
find.byKey(const ValueKey('synced-2026-10-17-Manual')),
findsOneWidget,
);
});
}

View File

@ -52,7 +52,7 @@ Future<void> _pump(WidgetTester tester, {bool withPc = false}) async {
}
Future<void> _scrollToSynced(WidgetTester tester) => tester.scrollUntilVisible(
find.text('SYNCED FROM PC'),
iconLabeled('SYNCED FROM PC'),
200,
scrollable: find.byType(Scrollable).first,
);
@ -75,7 +75,7 @@ void main() {
await _seedRun(tester, DateTime(2026, 7, 13, 12));
await _seedRun(tester, DateTime(2026, 7, 14, 12), sport: RunSport.walking);
await _pump(tester);
expect(find.text('No workouts yet.'), findsNothing);
expect(iconLabeled('No workouts yet.'), findsNothing);
expect(find.byKey(const Key('runs_strip')), findsOneWidget);
expect(find.text('5.20'), findsNWidgets(2));
expect(find.text('32:10'), findsNWidgets(2));
@ -95,8 +95,11 @@ void main() {
await _pump(tester, withPc: true);
await _scrollToSynced(tester);
// The PC's manual workout of that day stays; its run entry is gone.
expect(find.textContaining('2026-07-13 · Manual'), findsOneWidget);
expect(find.textContaining('2026-07-13 · Run'), findsNothing);
expect(
find.byKey(const ValueKey('synced-2026-07-13-Manual')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('synced-2026-07-13-Run')), findsNothing);
expect(find.text('5.20'), findsOneWidget);
});
@ -106,7 +109,7 @@ void main() {
await _seedRun(tester, DateTime(2026, 7, 20, 12));
await _pump(tester, withPc: true);
await _scrollToSynced(tester);
expect(find.textContaining('2026-07-13 · Run'), findsOneWidget);
expect(find.byKey(const ValueKey('synced-2026-07-13-Run')), findsOneWidget);
expect(find.text('5.20'), findsOneWidget);
});

View File

@ -37,7 +37,7 @@ void main() {
testWidgets('shows "No workouts yet." when history is empty', (tester) async {
await pumpHistory(tester, wrapHistory());
expect(find.text('No workouts yet.'), findsOneWidget);
expect(iconLabeled('No workouts yet.'), findsOneWidget);
});
testWidgets('shows session rows when history has data', (tester) async {
@ -58,7 +58,7 @@ void main() {
});
await seedSession(tester, json);
await pumpHistory(tester, wrapHistory());
expect(find.textContaining('Workout A'), findsWidgets);
expect(find.text('A · 2024-06-01'), findsWidgets);
});
testWidgets('exercise picker shows "Total (all workouts)" initially', (
@ -79,7 +79,7 @@ void main() {
});
await seedSession(tester, json);
await pumpHistory(tester, wrapHistory());
expect(find.textContaining('Total'), findsOneWidget);
expect(iconLabeled('Total (all workouts)'), findsOneWidget);
});
testWidgets('calendar prev/next month navigation works', (tester) async {

View File

@ -60,20 +60,20 @@ void main() {
),
);
final threshold = seeded.successThreshold;
final upRow = find.textContaining(RegExp(r'more to ↑'));
final upRow = find.textContaining(RegExp(r'^\d+ ↑'));
await openExercise(tester, name);
await tester.pump();
expect(
tester.widget<Text>(upRow).data,
startsWith('$threshold more'),
startsWith('$threshold ↑'),
reason: 'first frame: nothing counted yet, every dot still to fill',
);
await tester.pumpAndSettle();
expect(
tester.widget<Text>(upRow).data,
startsWith('${threshold - 2} more'),
startsWith('${threshold - 2} ↑'),
reason: 'settled: exactly the stored streak, never an invented one',
);
});
@ -88,8 +88,8 @@ void main() {
await openExercise(tester, name);
await tester.pump();
expect(
tester.widget<Text>(find.textContaining(RegExp(r'more to ↑'))).data,
startsWith('${seeded.successThreshold} more'),
tester.widget<Text>(find.textContaining(RegExp(r'^\d+ ↑'))).data,
startsWith('${seeded.successThreshold} ↑'),
);
});
}

View File

@ -23,7 +23,7 @@ void main() {
),
),
);
expect(find.text('June 2024'), findsOneWidget);
expect(find.text('06.2024'), findsOneWidget);
});
testWidgets('shows day-of-week headers', (tester) async {
@ -37,8 +37,8 @@ void main() {
),
),
);
expect(find.text('Mo'), findsOneWidget);
expect(find.text('Su'), findsOneWidget);
expect(find.bySemanticsLabel('Monday'), findsOneWidget);
expect(find.bySemanticsLabel('Sunday'), findsOneWidget);
});
testWidgets('calls onPrevMonth when left arrow tapped', (tester) async {
@ -101,7 +101,7 @@ void main() {
),
),
);
expect(find.text('September 2024'), findsOneWidget);
expect(find.text('09.2024'), findsOneWidget);
});
testWidgets('renders January (first month name) correctly', (tester) async {
@ -115,7 +115,7 @@ void main() {
),
),
);
expect(find.text('January 2024'), findsOneWidget);
expect(find.text('01.2024'), findsOneWidget);
});
testWidgets('renders December (last month name) correctly', (tester) async {
@ -129,7 +129,7 @@ void main() {
),
),
);
expect(find.text('December 2024'), findsOneWidget);
expect(find.text('12.2024'), findsOneWidget);
});
});
}