feat(train): Home opens on today's figures from the band (ADR 0030, D60)

While the Loop gave figures for today, Home shows them above the week:
steps, active time, last night's sleep and Nightly Recharge, as Profile
lists a day, tapped opening the day's page. Shown above Home's message
too, before a first workout; the week waits for one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-31 18:52:32 +01:00
commit a552b7a0fa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 172 additions and 18 deletions

View file

@ -578,5 +578,6 @@
"profileYearEmpty": "Dieses Jahr noch keine Trainings.",
"bodyWeight": "{kg} kg",
"bodyResting": "Ruhepuls {bpm}",
"bodyMax": "Maximalpuls {bpm}"
"bodyMax": "Maximalpuls {bpm}",
"homeToday": "Heute"
}

View file

@ -732,5 +732,6 @@
"bodyResting": "resting {bpm} bpm",
"@bodyResting": {"placeholders": {"bpm": {"type": "int"}}},
"bodyMax": "max {bpm} bpm",
"@bodyMax": {"placeholders": {"bpm": {"type": "int"}}}
"@bodyMax": {"placeholders": {"bpm": {"type": "int"}}},
"homeToday": "Today"
}

View file

@ -3495,6 +3495,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'max {bpm} bpm'**
String bodyMax(int bpm);
/// No description provided for @homeToday.
///
/// In en, this message translates to:
/// **'Today'**
String get homeToday;
}
class _AppLocalizationsDelegate

View file

@ -2106,4 +2106,7 @@ class AppLocalizationsDe extends AppLocalizations {
String bodyMax(int bpm) {
return 'Maximalpuls $bpm';
}
@override
String get homeToday => 'Heute';
}

View file

@ -2094,6 +2094,9 @@ class AppLocalizationsEn extends AppLocalizations {
String bodyMax(int bpm) {
return 'max $bpm bpm';
}
@override
String get homeToday => 'Today';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -1,7 +1,24 @@
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/stored_day.dart';
import 'package:intl/intl.dart';
/// [stored]'s figures as far as the band gave them, as Profile lists the
/// day and Home shows today (D60): its steps, active time, the night's
/// sleep and its Nightly Recharge.
List<String> dayFigures(AppLocalizations l10n, StoredDay stored) {
final day = stored.day;
final active = day?.activeTime;
return [
if (day?.steps case final steps?) l10n.daysSteps(steps),
if (active != null && active > Duration.zero)
l10n.daysActive(hoursMinutes(l10n, active)),
if (stored.night?.asleep case final asleep?)
l10n.daysAsleep(hoursMinutes(l10n, asleep)),
if (day?.recharge?.recovery case final level?) l10n.daysRecharge(level),
];
}
/// [time] in hours and minutes, or minutes alone under an hour.
String hoursMinutes(AppLocalizations l10n, Duration time) {
final minutes = time.inMinutes;

View file

@ -70,17 +70,7 @@ class const _DayTile(final StoredDay _stored) extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final day = _stored.day;
final night = _stored.night;
final active = day?.activeTime;
final figures = [
if (day?.steps case final steps?) l10n.daysSteps(steps),
if (active != null && active > Duration.zero)
l10n.daysActive(hoursMinutes(l10n, active)),
if (night?.asleep case final asleep?)
l10n.daysAsleep(hoursMinutes(l10n, asleep)),
if (day?.recharge?.recovery case final level?) l10n.daysRecharge(level),
];
final figures = dayFigures(l10n, _stored);
return ListTile(
leading: const Icon(LucideIcons.calendarDays),
title: Text(dayTitle(locale, _stored.date)),

View file

@ -0,0 +1,67 @@
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/stored_day.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/day_screen.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/day_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Today atop Home (ADR 0030, D60), while the band gave figures for it:
/// its steps, active time, last night's sleep and Nightly Recharge, as
/// Profile lists the day; tapped, the day's page. Nothing on a day without
/// figures yet, nor for someone without a Loop.
class const TodayCard({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final now = clock.now();
final today = context.select<DaysCubit, StoredDay?>(
(cubit) => cubit.state.days
.where(
(stored) =>
stored.date.year == now.year &&
stored.date.month == now.month &&
stored.date.day == now.day,
)
.firstOrNull,
);
final figures = today == null ? const <String>[] : dayFigures(l10n, today);
if (today == null || figures.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SectionCard(
padding: EdgeInsets.zero,
child: ListTile(
leading: const Icon(LucideIcons.calendarDays),
title: Text(
l10n.homeToday,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
subtitle: Wrap(
spacing: 16,
children: [
for (final figure in figures)
Text(
figure,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => openDayScreen(context, today.date),
),
),
);
}
}

View file

@ -31,6 +31,13 @@ class const WeekCard({super.key}) extends StatelessWidget {
),
);
final time = workouts.time;
// Nothing to sum up before the first workout, which Home's message is
// about.
if (context.select<WorkoutsCubit, bool>(
(cubit) => cubit.state.visible.isEmpty,
)) {
return const SizedBox.shrink();
}
return SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,

View file

@ -12,7 +12,8 @@ import 'package:material_ui/material_ui.dart';
/// The person's workouts as home lists them (ADR 0030, D24): newest first
/// under the month each started in, those this device can't open last;
/// [empty] while there are none; [header], where given, above them.
/// [empty] while there are none; [header], where given, above them, and
/// above [empty] too.
class const WorkoutsList({
required final Widget empty,
final Widget? header,
@ -29,7 +30,17 @@ class const WorkoutsList({
}
final workouts = state.visible;
if (workouts.isEmpty) {
return empty;
return header == null
? empty
: ListView(
children: [
?header,
Padding(
padding: const EdgeInsets.only(top: 48),
child: empty,
),
],
);
}
final rows = <Widget>[];
String? heading;

View file

@ -1,6 +1,7 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/today_card.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/week_card.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workouts_list.dart';
import 'package:halkyon_train_flutter/src/settings/settings_screen.dart';
@ -9,7 +10,8 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Home (ADR 0030, D60): this week ([WeekCard]) over the person's workouts
/// Home (ADR 0030, D60): today from the Loop ([TodayCard]) and this week
/// ([WeekCard]) over the person's workouts
/// (D24), a reset of their keys
/// waiting above them (`KeysResetBanner`); the cog to Settings.
class const HomeTab({super.key}) extends StatelessWidget {
@ -32,7 +34,10 @@ class const HomeTab({super.key}) extends StatelessWidget {
),
child: const Padding(
padding: EdgeInsets.fromLTRB(12, 8, 12, 0),
child: WeekCard(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [TodayCard(), WeekCard()],
),
),
),
),

View file

@ -13,6 +13,7 @@ import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/today_card.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
@ -2317,6 +2318,43 @@ void main() {
findsOneWidget,
);
});
testWidgets("Home shows today's figures from the band, even before a "
"workout, opening the day's page (D60)", (tester) async {
await keepTuesday(tester);
await withClock(Clock.fixed(DateTime(2026, 10, 27, 18)), () async {
await start(tester, usedBefore: true, days: days);
final today = find.byType(TodayCard);
Finder inToday(String text) =>
find.descendant(of: today, matching: find.text(text));
expect(inToday('Today'), findsOneWidget);
expect(inToday('9876 steps'), findsOneWidget);
expect(inToday('1 h 50 min active'), findsOneWidget);
expect(inToday('7 h 50 min asleep'), findsOneWidget);
expect(inToday('Recharge 4 of 6'), findsOneWidget);
expect(find.text('No workouts yet'), findsOneWidget);
expect(
tester.getBottomLeft(today).dy,
lessThanOrEqualTo(tester.getTopLeft(find.text('No workouts yet')).dy),
);
await tester.tap(today);
await tester.pumpAndSettle();
expect(find.text('Heart rate'), findsOneWidget);
});
});
testWidgets('a day without figures for today shows no Today on Home', (
tester,
) async {
await keepTuesday(tester);
await withClock(Clock.fixed(DateTime(2026, 10, 28, 9)), () async {
await start(tester, usedBefore: true, days: days);
expect(find.text('Today'), findsNothing);
});
});
});
group('plans (ADR 0030, D42)', () {