feat(train): Profile opens on your body, the last 12 weeks and this year (ADR 0030, D60)

Above the days: a card with your body's weight and resting and maximal
heart rate, tapped opening Your body; the last 12 weeks' time trained as
bars, a week tapped showing its time, distance and workouts; and this
calendar year by activity. Home's week card reads its time as the days do.

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:46:26 +01:00
commit 5cfab59bd2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 713 additions and 46 deletions

File diff suppressed because one or more lines are too long

View file

@ -571,6 +571,12 @@
"homeWeekTime": "Zeit",
"homeWeekDistance": "Distanz",
"homeWeekWorkouts": "Trainings",
"homeWeekDuration": "{hours, plural, =0{{minutes} Min.} other{{hours} Std. {minutes} Min.}}",
"recordPlansHeading": "Intervallpläne"
"recordPlansHeading": "Intervallpläne",
"profileWeeksTitle": "Letzte 12 Wochen",
"profileWeekOf": "Woche vom {date}",
"profileWorkouts": "{count, plural, =1{1 Training} other{{count} Trainings}}",
"profileYearEmpty": "Dieses Jahr noch keine Trainings.",
"bodyWeight": "{kg} kg",
"bodyResting": "Ruhepuls {bpm}",
"bodyMax": "Maximalpuls {bpm}"
}

View file

@ -720,7 +720,17 @@
"homeWeekTime": "Time",
"homeWeekDistance": "Distance",
"homeWeekWorkouts": "Workouts",
"homeWeekDuration": "{hours, plural, =0{{minutes} min} other{{hours} h {minutes} min}}",
"@homeWeekDuration": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}},
"recordPlansHeading": "Interval plans"
"recordPlansHeading": "Interval plans",
"profileWeeksTitle": "Last 12 weeks",
"profileWeekOf": "Week of {date}",
"@profileWeekOf": {"placeholders": {"date": {"type": "String"}}},
"profileWorkouts": "{count, plural, =1{1 workout} other{{count} workouts}}",
"@profileWorkouts": {"placeholders": {"count": {"type": "int"}}},
"profileYearEmpty": "No workouts this year yet.",
"bodyWeight": "{kg} kg",
"@bodyWeight": {"placeholders": {"kg": {"type": "String"}}},
"bodyResting": "resting {bpm} bpm",
"@bodyResting": {"placeholders": {"bpm": {"type": "int"}}},
"bodyMax": "max {bpm} bpm",
"@bodyMax": {"placeholders": {"bpm": {"type": "int"}}}
}

View file

@ -154,12 +154,6 @@ abstract class AppLocalizations {
/// **'Record one from the Record tab.'**
String get homeRecordHint;
/// No description provided for @recordAction.
///
/// In en, this message translates to:
/// **'Record'**
String get recordAction;
/// No description provided for @recordBack.
///
/// In en, this message translates to:
@ -3454,17 +3448,53 @@ abstract class AppLocalizations {
/// **'Workouts'**
String get homeWeekWorkouts;
/// No description provided for @homeWeekDuration.
///
/// In en, this message translates to:
/// **'{hours, plural, =0{{minutes} min} other{{hours} h {minutes} min}}'**
String homeWeekDuration(int hours, int minutes);
/// No description provided for @recordPlansHeading.
///
/// In en, this message translates to:
/// **'Interval plans'**
String get recordPlansHeading;
/// No description provided for @profileWeeksTitle.
///
/// In en, this message translates to:
/// **'Last 12 weeks'**
String get profileWeeksTitle;
/// No description provided for @profileWeekOf.
///
/// In en, this message translates to:
/// **'Week of {date}'**
String profileWeekOf(String date);
/// No description provided for @profileWorkouts.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 workout} other{{count} workouts}}'**
String profileWorkouts(int count);
/// No description provided for @profileYearEmpty.
///
/// In en, this message translates to:
/// **'No workouts this year yet.'**
String get profileYearEmpty;
/// No description provided for @bodyWeight.
///
/// In en, this message translates to:
/// **'{kg} kg'**
String bodyWeight(String kg);
/// No description provided for @bodyResting.
///
/// In en, this message translates to:
/// **'resting {bpm} bpm'**
String bodyResting(int bpm);
/// No description provided for @bodyMax.
///
/// In en, this message translates to:
/// **'max {bpm} bpm'**
String bodyMax(int bpm);
}
class _AppLocalizationsDelegate

View file

@ -39,9 +39,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get homeRecordHint => 'Zeichne eins im Tab Aufzeichnen auf.';
@override
String get recordAction => 'Aufzeichnen';
@override
String get recordBack => 'Zurück zur Aufzeichnung';
@ -2071,16 +2068,42 @@ class AppLocalizationsDe extends AppLocalizations {
String get homeWeekWorkouts => 'Trainings';
@override
String homeWeekDuration(int hours, int minutes) {
String get recordPlansHeading => 'Intervallpläne';
@override
String get profileWeeksTitle => 'Letzte 12 Wochen';
@override
String profileWeekOf(String date) {
return 'Woche vom $date';
}
@override
String profileWorkouts(int count) {
String _temp0 = intl.Intl.pluralLogic(
hours,
count,
locale: localeName,
other: '$hours Std. $minutes Min.',
zero: '$minutes Min.',
other: '$count Trainings',
one: '1 Training',
);
return '$_temp0';
}
@override
String get recordPlansHeading => 'Intervallpläne';
String get profileYearEmpty => 'Dieses Jahr noch keine Trainings.';
@override
String bodyWeight(String kg) {
return '$kg kg';
}
@override
String bodyResting(int bpm) {
return 'Ruhepuls $bpm';
}
@override
String bodyMax(int bpm) {
return 'Maximalpuls $bpm';
}
}

View file

@ -38,9 +38,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get homeRecordHint => 'Record one from the Record tab.';
@override
String get recordAction => 'Record';
@override
String get recordBack => 'Back to the recording';
@ -2059,18 +2056,44 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeWeekWorkouts => 'Workouts';
@override
String homeWeekDuration(int hours, int minutes) {
String get recordPlansHeading => 'Interval plans';
@override
String get profileWeeksTitle => 'Last 12 weeks';
@override
String profileWeekOf(String date) {
return 'Week of $date';
}
@override
String profileWorkouts(int count) {
String _temp0 = intl.Intl.pluralLogic(
hours,
count,
locale: localeName,
other: '$hours h $minutes min',
zero: '$minutes min',
other: '$count workouts',
one: '1 workout',
);
return '$_temp0';
}
@override
String get recordPlansHeading => 'Interval plans';
String get profileYearEmpty => 'No workouts this year yet.';
@override
String bodyWeight(String kg) {
return '$kg kg';
}
@override
String bodyResting(int bpm) {
return 'resting $bpm bpm';
}
@override
String bodyMax(int bpm) {
return 'max $bpm bpm';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -11,8 +11,9 @@ import 'package:material_ui/material_ui.dart';
/// The person's days as home lists them (ADR 0030, D37): newest first, each
/// with its steps and active time, and the night that ended it with its
/// sleep and Nightly Recharge. Pulling reads the band, where this phone
/// holds one.
class const DaysList({super.key}) extends StatelessWidget {
/// holds one. [header], where given, above them.
class const DaysList({final Widget? header, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -24,6 +25,7 @@ class const DaysList({super.key}) extends StatelessWidget {
final list = state.days.isEmpty
? ListView(
children: [
?header,
Padding(
padding: const EdgeInsets.only(top: 64),
child: EmptyListMessage(
@ -37,6 +39,7 @@ class const DaysList({super.key}) extends StatelessWidget {
: ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
?header,
for (final day in state.days)
Center(
child: ConstrainedBox(

View file

@ -0,0 +1,78 @@
import 'dart:async';
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/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// *Your body* atop Profile (ADR 0030, D60): the figures that matter most
/// for training (weight, resting and maximal heart rate) as far as the
/// person gave them, else what the screen is for; tapped, the screen.
/// Read again whenever the profile changes, here or on another device;
/// nothing where this device can't open it.
class const BodyCard({super.key}) extends StatefulWidget {
@override
State<BodyCard> createState() => _BodyCardState();
}
class _BodyCardState() extends State<BodyCard> {
/// What was read; null while reading, or where it doesn't open here.
ProfileContent? _profile;
StreamSubscription<void>? _changes;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_changes == null) {
final profile = PersonScope.of<TrainPerson>(context).profile;
_changes = profile.changes.listen((_) => unawaited(_read()));
unawaited(_read());
}
}
Future<void> _read() async {
final profile = await PersonScope.of<TrainPerson>(context).profile.read();
if (mounted) {
setState(() => _profile = profile);
}
}
@override
void dispose() {
unawaited(_changes?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
final profile = _profile;
if (profile == null) {
return const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final figures = [
if (profile.weightKg case final kg?)
l10n.bodyWeight(NumberFormat('0.#', locale).format(kg)),
if (profile.restingHeartRate case final bpm?) l10n.bodyResting(bpm),
if (profile.maxHeartRate case final bpm?) l10n.bodyMax(bpm),
];
return SectionCard(
padding: EdgeInsets.zero,
child: ListTile(
leading: const Icon(LucideIcons.personStanding),
title: Text(l10n.profileTitle),
subtitle: Text(
figures.isEmpty ? l10n.profileHint : figures.join(' · '),
),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => openProfileScreen(context),
),
);
}
}

View file

@ -1,6 +1,11 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The time [workout] counts as trained: its moving time, else its start
/// to its end.
Duration trainedTime(WorkoutContent workout) =>
workout.movingTime ?? workout.endedAt.difference(workout.startedAt);
/// The week [now] is in, summed up for Home (ADR 0030, D60), as Strava's:
/// the workouts started in it, from the day the person's weeks start on
/// ([firstDay], as [DateTime.weekday]), in local time. A workout counts its
@ -29,8 +34,7 @@ WeekSummary weekSummary(
}
count++;
metres += workout.distanceMetres ?? 0;
times[index] +=
workout.movingTime ?? workout.endedAt.difference(workout.startedAt);
times[index] += trainedTime(workout);
}
return WeekSummary._(
start: days.first,
@ -71,3 +75,71 @@ final class const WeekSummary._({
@override
List<Object?> get props => [start, days, today, count, metres];
}
/// The [count] weeks up to the one [now] is in, oldest first, as Profile
/// charts them (ADR 0030, D60); each as [weekSummary] sums it up.
List<WeekSummary> weeksBack(
Iterable<WorkoutContent> workouts, {
required DateTime now,
required int firstDay,
int count = 12,
}) {
final list = workouts.toList();
return [
for (var back = count - 1; back >= 0; back--)
weekSummary(
list,
// Days by the calendar, as a clock change breaks 7 × 24 hours.
now: DateTime(now.year, now.month, now.day - 7 * back, 12),
firstDay: firstDay,
),
];
}
/// The calendar year [now] is in, as Profile sums it up (ADR 0030, D60):
/// for each activity trained in it, the workouts, their time and distance,
/// the most trained first; each workout in the year it started, local time.
YearSummary yearSummary(
Iterable<WorkoutContent> workouts, {
required DateTime now,
}) {
final totals = <Activity, ({int count, Duration time, int metres})>{};
for (final workout in workouts) {
if (workout.startedAt.toLocal().year != now.year) {
continue;
}
final (:count, :time, :metres) =
totals[workout.activity] ?? (count: 0, time: Duration.zero, metres: 0);
totals[workout.activity] = (
count: count + 1,
time: time + trainedTime(workout),
metres: metres + (workout.distanceMetres ?? 0),
);
}
return YearSummary._(
year: now.year,
activities: [
for (final MapEntry(key: activity, value: total) in totals.entries)
(
activity: activity,
count: total.count,
time: total.time,
metres: total.metres,
),
]..sort((a, b) => b.time.compareTo(a.time)),
);
}
/// One calendar year's workouts, summed up by activity.
final class const YearSummary._({
required final int year,
/// Each activity trained in it, the most trained first.
required final List<
({Activity activity, int count, Duration time, int metres})
>
activities,
}) with Equatable {
@override
List<Object?> get props => [year, activities];
}

View file

@ -0,0 +1,182 @@
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/widget/day_texts.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/week_summary.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
List<WorkoutContent> _opened(WorkoutsCubit cubit) => [
for (final workout in cubit.state.visible) ?workout.content,
];
/// The last 12 weeks' time trained as bars on Profile (ADR 0030, D60), as
/// Strava's: the week tapped, this one at first, with its time, distance and
/// workouts beneath; weeks starting on the day the person's language starts
/// them on, as Home's [weekSummary].
class const WeeksCard({super.key}) extends StatefulWidget {
@override
State<WeeksCard> createState() => _WeeksCardState();
}
class _WeeksCardState() extends State<WeeksCard> {
static const double _height = 64;
/// The week chosen, from the oldest; null for this one.
int? _chosen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final scheme = theme.colorScheme;
// Material's index counts from Sunday, 0; DateTime's from Monday, 1.
final sunday0 = MaterialLocalizations.of(context).firstDayOfWeekIndex;
final weeks = context.select<WorkoutsCubit, List<WeekSummary>>(
(cubit) => weeksBack(
_opened(cubit),
now: clock.now(),
firstDay: sunday0 == 0 ? DateTime.sunday : sunday0,
),
);
final chosen = _chosen ?? weeks.length - 1;
final week = weeks[chosen];
final longest = weeks.fold(
Duration.zero,
(longest, week) => week.time > longest ? week.time : longest,
);
return SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.profileWeeksTitle,
style: theme.textTheme.titleSmall?.copyWith(color: scheme.primary),
),
const SizedBox(height: 12),
SizedBox(
height: _height,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final (index, week) in weeks.indexed)
Expanded(
child: Semantics(
button: true,
selected: index == chosen,
label: l10n.profileWeekOf(
DateFormat.MMMd(locale).format(week.start),
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _chosen = index),
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
width: 12,
height: longest == Duration.zero
? 4
: 4 +
(_height - 4) *
week.time.inSeconds /
longest.inSeconds,
decoration: BoxDecoration(
color: switch ((index == chosen, week.time)) {
(true, _) => scheme.primary,
(false, Duration.zero) =>
scheme.surfaceContainerHighest,
(false, _) => scheme.primary.withValues(
alpha: 0.4,
),
},
borderRadius: BorderRadius.circular(6),
),
),
),
),
),
),
],
),
),
const SizedBox(height: 12),
Text(
chosen == weeks.length - 1
? l10n.homeWeekTitle
: l10n.profileWeekOf(
DateFormat.MMMd(locale).format(week.start),
),
style: theme.textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
Text(
[
hoursMinutes(l10n, week.time),
if (week.metres > 0) distanceText(l10n, locale, week.metres),
l10n.profileWorkouts(week.count),
].join(' · '),
style: theme.textTheme.titleMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
}
}
/// This calendar year on Profile (ADR 0030, D60): for each activity trained
/// in it, its workouts, time and distance, the most trained first.
class const YearCard({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final year = context.select<WorkoutsCubit, YearSummary>(
(cubit) => yearSummary(_opened(cubit), now: clock.now()),
);
return SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
'${year.year}',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
if (year.activities.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Text(l10n.profileYearEmpty),
),
for (final total in year.activities)
ListTile(
leading: Icon(activityIcon(total.activity)),
title: Text(activityName(l10n, total.activity)),
subtitle: Text(
[
l10n.profileWorkouts(total.count),
hoursMinutes(l10n, total.time),
if (total.metres > 0)
distanceText(l10n, locale, total.metres),
].join(' · '),
),
),
],
),
);
}
}

View file

@ -1,6 +1,7 @@
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/widget/day_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/week_summary.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
@ -45,10 +46,7 @@ class const WeekCard({super.key}) extends StatelessWidget {
children: [
_Figure(
label: l10n.homeWeekTime,
value: l10n.homeWeekDuration(
time.inHours,
time.inMinutes.remainder(60),
),
value: hoursMinutes(l10n, time),
),
_Figure(
label: l10n.homeWeekDistance,

View file

@ -3,12 +3,16 @@ 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/days_list.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plans_list.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/body_card.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
import 'package:halkyon_train_flutter/src/settings/settings_screen.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Profile (ADR 0030, D60): the person's days from the Loop (D37), and the
/// cog to Settings. Where the device doesn't record, and so has no
/// Profile (ADR 0030, D60): *Your body* ([BodyCard]), the last 12 weeks
/// ([WeeksCard]) and this year ([YearCard]) over the person's days from the
/// Loop (D37), and the cog to Settings. Where the device doesn't record, and so has no
/// Record tab, the interval plans (D42) are here beside the days.
class const ProfileTab({super.key}) extends StatelessWidget {
@override
@ -20,7 +24,7 @@ class const ProfileTab({super.key}) extends StatelessWidget {
return TabScreen(
title: l10n.shellTabProfile,
actions: const [settings],
body: const DaysList(),
body: const DaysList(header: _Progress(daysHeading: true)),
);
}
return DefaultTabController(
@ -34,7 +38,51 @@ class const ProfileTab({super.key}) extends StatelessWidget {
Tab(text: l10n.homePlans),
],
),
body: const TabBarView(children: [DaysList(), PlansList()]),
body: const TabBarView(
children: [
DaysList(header: _Progress(daysHeading: false)),
PlansList(),
],
),
),
);
}
}
/// Profile's cards over the days, a heading of their own unless a tab
/// names them ([daysHeading]).
class const _Progress({required final bool daysHeading})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const BodyCard(),
const SizedBox(height: 12),
const WeeksCard(),
const SizedBox(height: 12),
const YearCard(),
if (!daysHeading) const SizedBox(height: 8),
if (daysHeading)
Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 4),
child: Text(
AppLocalizations.of(context).homeDays,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
],
),
),
),
);
}

View file

@ -29,6 +29,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/body_card.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/announce_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
@ -44,6 +45,7 @@ import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dar
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/week_card.dart';
import 'package:halkyon_train_flutter/src/tabs/maps_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
@ -661,6 +663,65 @@ void main() {
expect(find.text('52'), findsOneWidget);
expect(find.text('Female'), findsOneWidget);
});
testWidgets("Profile's card shows the figures given, opens Your body, and "
'follows a change (D60)', (tester) async {
await tester.runAsync(
() => profile.save(
const ProfileContent(
weightKg: 72.5,
restingHeartRate: 52,
maxHeartRate: 188,
),
),
);
await start(tester, usedBefore: true, profile: profile);
await goTab(tester, 'Profile');
await tester.runAsync(pumpEventQueue);
await tester.pump();
final card = find.byType(BodyCard);
expect(
find.descendant(
of: card,
matching: find.text('72.5 kg · resting 52 bpm · max 188 bpm'),
),
findsOneWidget,
);
await tester.runAsync(
() => profile.save(const ProfileContent(restingHeartRate: 50)),
);
await tester.runAsync(pumpEventQueue);
await tester.pump();
expect(
find.descendant(of: card, matching: find.text('resting 50 bpm')),
findsOneWidget,
);
await tester.tap(card);
await tester.pumpAndSettle();
expect(find.text('50'), findsOneWidget);
});
testWidgets("without figures, Profile's card says what they're for", (
tester,
) async {
await start(tester, usedBefore: true, profile: profile);
await goTab(tester, 'Profile');
await tester.runAsync(pumpEventQueue);
await tester.pump();
expect(
find.descendant(
of: find.byType(BodyCard),
matching: find.text(
'What your band and your heart-rate zones work from',
),
),
findsOneWidget,
);
});
});
group('the Polar Loop (ADR 0030, D34, D35)', () {
@ -3143,6 +3204,52 @@ void main() {
});
});
testWidgets('Profile charts the last 12 weeks, this week chosen, a week '
'tapped showing its own, and sums up the year by activity (D60)', (
tester,
) async {
tester.platformDispatcher.localesTestValue = const [Locale('en', 'GB')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
// Saturday, 31 October 2026.
await withClock(Clock.fixed(DateTime(2026, 10, 31, 15)), () async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await saveRow();
await saveRun(DateTime(2026, 9, 3, 18));
await startWith(tester);
await goTab(tester, 'Profile');
final weeks = find.byType(WeeksCard);
Finder inWeeks(String text) =>
find.descendant(of: weeks, matching: find.text(text));
expect(inWeeks('Last 12 weeks'), findsOneWidget);
expect(inWeeks('This week'), findsOneWidget);
// 12:30 running and 30:00 rowing.
expect(inWeeks('42 min · 2.50 km · 2 workouts'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('Week of 31 Aug'));
await tester.pump();
expect(inWeeks('Week of 31 Aug'), findsOneWidget);
expect(inWeeks('12 min · 2.50 km · 1 workout'), findsOneWidget);
final year = find.byType(YearCard);
expect(
find.descendant(of: year, matching: find.text('2026')),
findsOneWidget,
);
double top(String text) => tester
.getTopLeft(find.descendant(of: year, matching: find.text(text)))
.dy;
expect(top('Rowing indoors'), lessThan(top('Running')));
expect(
find.descendant(
of: year,
matching: find.text('2 workouts · 25 min · 5.00 km'),
),
findsOneWidget,
);
});
});
testWidgets("a workout's page shows its map, totals, splits and charts, "
'and where the map comes from', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));

View file

@ -109,4 +109,84 @@ void main() {
expect(week.longestDay, const Duration(hours: 1));
});
group('the weeks Profile shows (D60)', () {
test('twelve, oldest first, the last this one', () {
final weeks = weeksBack(
[
workout(DateTime(2026, 10, 27, 7), moving: const Duration(hours: 1)),
workout(DateTime(2026, 8, 12, 7), moving: const Duration(hours: 2)),
// Thirteen weeks back: out.
workout(DateTime(2026, 7, 29, 7)),
],
now: now,
firstDay: DateTime.monday,
);
expect(weeks, hasLength(12));
expect(weeks.last.start, DateTime(2026, 10, 26));
expect(weeks.first.start, DateTime(2026, 8, 10));
expect(weeks.last.time, const Duration(hours: 1));
expect(weeks.first.time, const Duration(hours: 2));
expect(weeks.map((week) => week.count).reduce((a, b) => a + b), 2);
});
test('by the calendar across a change of the clocks', () {
// Clocks went back on 25 October 2026 in Europe.
final weeks = weeksBack(
const [],
now: now,
firstDay: DateTime.monday,
);
for (final week in weeks) {
expect(week.start.weekday, DateTime.monday);
expect(week.start.hour, 0);
}
});
});
group('the year Profile shows (D60)', () {
test('per activity, the most trained first, this calendar year alone', () {
final year = yearSummary([
workout(
DateTime(2026, 3, 1, 7),
moving: const Duration(hours: 1),
metres: 10000,
),
workout(
DateTime(2026, 10, 30, 7),
moving: const Duration(hours: 1),
metres: 12000,
),
WorkoutContent(
activity: Activity.cycling,
startedAt: DateTime(2026, 6, 1, 9).toUtc(),
endedAt: DateTime(2026, 6, 1, 12).toUtc(),
distanceMetres: 80000,
),
// Last year's.
workout(DateTime(2025, 12, 31, 20), metres: 5000),
], now: now);
expect(year.year, 2026);
expect(year.activities, [
(
activity: Activity.cycling,
count: 1,
time: const Duration(hours: 3),
metres: 80000,
),
(
activity: Activity.running,
count: 2,
time: const Duration(hours: 2),
metres: 22000,
),
]);
});
test('a year without workouts lists none', () {
expect(yearSummary(const [], now: now).activities, isEmpty);
});
});
}