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:
parent
5cfab59bd2
commit
a552b7a0fa
14 changed files with 172 additions and 18 deletions
File diff suppressed because one or more lines are too long
|
|
@ -578,5 +578,6 @@
|
||||||
"profileYearEmpty": "Dieses Jahr noch keine Trainings.",
|
"profileYearEmpty": "Dieses Jahr noch keine Trainings.",
|
||||||
"bodyWeight": "{kg} kg",
|
"bodyWeight": "{kg} kg",
|
||||||
"bodyResting": "Ruhepuls {bpm}",
|
"bodyResting": "Ruhepuls {bpm}",
|
||||||
"bodyMax": "Maximalpuls {bpm}"
|
"bodyMax": "Maximalpuls {bpm}",
|
||||||
|
"homeToday": "Heute"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -732,5 +732,6 @@
|
||||||
"bodyResting": "resting {bpm} bpm",
|
"bodyResting": "resting {bpm} bpm",
|
||||||
"@bodyResting": {"placeholders": {"bpm": {"type": "int"}}},
|
"@bodyResting": {"placeholders": {"bpm": {"type": "int"}}},
|
||||||
"bodyMax": "max {bpm} bpm",
|
"bodyMax": "max {bpm} bpm",
|
||||||
"@bodyMax": {"placeholders": {"bpm": {"type": "int"}}}
|
"@bodyMax": {"placeholders": {"bpm": {"type": "int"}}},
|
||||||
|
"homeToday": "Today"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3495,6 +3495,12 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'max {bpm} bpm'**
|
/// **'max {bpm} bpm'**
|
||||||
String bodyMax(int bpm);
|
String bodyMax(int bpm);
|
||||||
|
|
||||||
|
/// No description provided for @homeToday.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Today'**
|
||||||
|
String get homeToday;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2106,4 +2106,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String bodyMax(int bpm) {
|
String bodyMax(int bpm) {
|
||||||
return 'Maximalpuls $bpm';
|
return 'Maximalpuls $bpm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeToday => 'Heute';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2094,6 +2094,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String bodyMax(int bpm) {
|
String bodyMax(int bpm) {
|
||||||
return 'max $bpm bpm';
|
return 'max $bpm bpm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeToday => 'Today';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,24 @@
|
||||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
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/day.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/day/model/stored_day.dart';
|
||||||
import 'package:intl/intl.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.
|
/// [time] in hours and minutes, or minutes alone under an hour.
|
||||||
String hoursMinutes(AppLocalizations l10n, Duration time) {
|
String hoursMinutes(AppLocalizations l10n, Duration time) {
|
||||||
final minutes = time.inMinutes;
|
final minutes = time.inMinutes;
|
||||||
|
|
|
||||||
|
|
@ -70,17 +70,7 @@ class const _DayTile(final StoredDay _stored) extends StatelessWidget {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final locale = Localizations.localeOf(context).toString();
|
final locale = Localizations.localeOf(context).toString();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final day = _stored.day;
|
final figures = dayFigures(l10n, _stored);
|
||||||
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),
|
|
||||||
];
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(LucideIcons.calendarDays),
|
leading: const Icon(LucideIcons.calendarDays),
|
||||||
title: Text(dayTitle(locale, _stored.date)),
|
title: Text(dayTitle(locale, _stored.date)),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,13 @@ class const WeekCard({super.key}) extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final time = workouts.time;
|
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(
|
return SectionCard(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// The person's workouts as home lists them (ADR 0030, D24): newest first
|
/// 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;
|
/// 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({
|
class const WorkoutsList({
|
||||||
required final Widget empty,
|
required final Widget empty,
|
||||||
final Widget? header,
|
final Widget? header,
|
||||||
|
|
@ -29,7 +30,17 @@ class const WorkoutsList({
|
||||||
}
|
}
|
||||||
final workouts = state.visible;
|
final workouts = state.visible;
|
||||||
if (workouts.isEmpty) {
|
if (workouts.isEmpty) {
|
||||||
return empty;
|
return header == null
|
||||||
|
? empty
|
||||||
|
: ListView(
|
||||||
|
children: [
|
||||||
|
?header,
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 48),
|
||||||
|
child: empty,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final rows = <Widget>[];
|
final rows = <Widget>[];
|
||||||
String? heading;
|
String? heading;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_train_flutter/l10n/app_localizations.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/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/week_card.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/workouts_list.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/widget/workouts_list.dart';
|
||||||
import 'package:halkyon_train_flutter/src/settings/settings_screen.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:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.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
|
/// (D24), a reset of their keys
|
||||||
/// waiting above them (`KeysResetBanner`); the cog to Settings.
|
/// waiting above them (`KeysResetBanner`); the cog to Settings.
|
||||||
class const HomeTab({super.key}) extends StatelessWidget {
|
class const HomeTab({super.key}) extends StatelessWidget {
|
||||||
|
|
@ -32,7 +34,10 @@ class const HomeTab({super.key}) extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.fromLTRB(12, 8, 12, 0),
|
padding: EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||||
child: WeekCard(),
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [TodayCard(), WeekCard()],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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/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/day_repository.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.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_connect.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.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';
|
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
|
||||||
|
|
@ -2317,6 +2318,43 @@ void main() {
|
||||||
findsOneWidget,
|
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)', () {
|
group('plans (ADR 0030, D42)', () {
|
||||||
|
|
|
||||||
|
|
@ -1523,6 +1523,11 @@ Strava. What was found, 27 Oct:
|
||||||
(`YearCard`, `yearSummary`), over the days; over the year's totals alone and over
|
(`YearCard`, `yearSummary`), over the days; over the year's totals alone and over
|
||||||
progress from the Loop's days, which someone without a Loop wouldn't have (the
|
progress from the Loop's days, which someone without a Loop wouldn't have (the
|
||||||
developer's, 31 Oct). Every duration reads as the days' do (`hoursMinutes`).
|
developer's, 31 Oct). Every duration reads as the days' do (`hoursMinutes`).
|
||||||
|
Home opens on today from the band (`TodayCard`: steps, active time, last night's sleep
|
||||||
|
and Nightly Recharge, as Profile lists a day, `dayFigures`; tapped, the day's page,
|
||||||
|
in Profile's tab), shown while the band gave figures for today, and so for someone
|
||||||
|
with a Loop alone; above the week, which waits for the first workout, and above
|
||||||
|
Home's message while there is none.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue