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
12 changed files with 172 additions and 18 deletions
|
|
@ -578,5 +578,6 @@
|
|||
"profileYearEmpty": "Dieses Jahr noch keine Trainings.",
|
||||
"bodyWeight": "{kg} kg",
|
||||
"bodyResting": "Ruhepuls {bpm}",
|
||||
"bodyMax": "Maximalpuls {bpm}"
|
||||
"bodyMax": "Maximalpuls {bpm}",
|
||||
"homeToday": "Heute"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2106,4 +2106,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String bodyMax(int bpm) {
|
||||
return 'Maximalpuls $bpm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeToday => 'Heute';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue