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
15 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", "homeWeekTime": "Zeit",
"homeWeekDistance": "Distanz", "homeWeekDistance": "Distanz",
"homeWeekWorkouts": "Trainings", "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", "homeWeekTime": "Time",
"homeWeekDistance": "Distance", "homeWeekDistance": "Distance",
"homeWeekWorkouts": "Workouts", "homeWeekWorkouts": "Workouts",
"homeWeekDuration": "{hours, plural, =0{{minutes} min} other{{hours} h {minutes} min}}", "recordPlansHeading": "Interval plans",
"@homeWeekDuration": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}}, "profileWeeksTitle": "Last 12 weeks",
"recordPlansHeading": "Interval plans" "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.'** /// **'Record one from the Record tab.'**
String get homeRecordHint; String get homeRecordHint;
/// No description provided for @recordAction.
///
/// In en, this message translates to:
/// **'Record'**
String get recordAction;
/// No description provided for @recordBack. /// No description provided for @recordBack.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3454,17 +3448,53 @@ abstract class AppLocalizations {
/// **'Workouts'** /// **'Workouts'**
String get homeWeekWorkouts; 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. /// No description provided for @recordPlansHeading.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Interval plans'** /// **'Interval plans'**
String get recordPlansHeading; 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 class _AppLocalizationsDelegate

View file

@ -39,9 +39,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get homeRecordHint => 'Zeichne eins im Tab Aufzeichnen auf.'; String get homeRecordHint => 'Zeichne eins im Tab Aufzeichnen auf.';
@override
String get recordAction => 'Aufzeichnen';
@override @override
String get recordBack => 'Zurück zur Aufzeichnung'; String get recordBack => 'Zurück zur Aufzeichnung';
@ -2071,16 +2068,42 @@ class AppLocalizationsDe extends AppLocalizations {
String get homeWeekWorkouts => 'Trainings'; String get homeWeekWorkouts => 'Trainings';
@override @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( String _temp0 = intl.Intl.pluralLogic(
hours, count,
locale: localeName, locale: localeName,
other: '$hours Std. $minutes Min.', other: '$count Trainings',
zero: '$minutes Min.', one: '1 Training',
); );
return '$_temp0'; return '$_temp0';
} }
@override @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 @override
String get homeRecordHint => 'Record one from the Record tab.'; String get homeRecordHint => 'Record one from the Record tab.';
@override
String get recordAction => 'Record';
@override @override
String get recordBack => 'Back to the recording'; String get recordBack => 'Back to the recording';
@ -2059,18 +2056,44 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeWeekWorkouts => 'Workouts'; String get homeWeekWorkouts => 'Workouts';
@override @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( String _temp0 = intl.Intl.pluralLogic(
hours, count,
locale: localeName, locale: localeName,
other: '$hours h $minutes min', other: '$count workouts',
zero: '$minutes min', one: '1 workout',
); );
return '$_temp0'; return '$_temp0';
} }
@override @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`). /// 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 /// 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 /// 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 /// sleep and Nightly Recharge. Pulling reads the band, where this phone
/// holds one. /// holds one. [header], where given, above them.
class const DaysList({super.key}) extends StatelessWidget { class const DaysList({final Widget? header, super.key})
extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -24,6 +25,7 @@ class const DaysList({super.key}) extends StatelessWidget {
final list = state.days.isEmpty final list = state.days.isEmpty
? ListView( ? ListView(
children: [ children: [
?header,
Padding( Padding(
padding: const EdgeInsets.only(top: 64), padding: const EdgeInsets.only(top: 64),
child: EmptyListMessage( child: EmptyListMessage(
@ -37,6 +39,7 @@ class const DaysList({super.key}) extends StatelessWidget {
: ListView( : ListView(
padding: const EdgeInsets.only(bottom: 88), padding: const EdgeInsets.only(bottom: 88),
children: [ children: [
?header,
for (final day in state.days) for (final day in state.days)
Center( Center(
child: ConstrainedBox( 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:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.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 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 /// 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 /// ([firstDay], as [DateTime.weekday]), in local time. A workout counts its
@ -29,8 +34,7 @@ WeekSummary weekSummary(
} }
count++; count++;
metres += workout.distanceMetres ?? 0; metres += workout.distanceMetres ?? 0;
times[index] += times[index] += trainedTime(workout);
workout.movingTime ?? workout.endedAt.difference(workout.startedAt);
} }
return WeekSummary._( return WeekSummary._(
start: days.first, start: days.first,
@ -71,3 +75,71 @@ final class const WeekSummary._({
@override @override
List<Object?> get props => [start, days, today, count, metres]; 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:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.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/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/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/week_summary.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'; import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
@ -45,10 +46,7 @@ class const WeekCard({super.key}) extends StatelessWidget {
children: [ children: [
_Figure( _Figure(
label: l10n.homeWeekTime, label: l10n.homeWeekTime,
value: l10n.homeWeekDuration( value: hoursMinutes(l10n, time),
time.inHours,
time.inMinutes.remainder(60),
),
), ),
_Figure( _Figure(
label: l10n.homeWeekDistance, 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/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/days_list.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/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/settings/settings_screen.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_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'; import 'package:material_ui/material_ui.dart';
/// Profile (ADR 0030, D60): the person's days from the Loop (D37), and the /// Profile (ADR 0030, D60): *Your body* ([BodyCard]), the last 12 weeks
/// cog to Settings. Where the device doesn't record, and so has no /// ([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. /// Record tab, the interval plans (D42) are here beside the days.
class const ProfileTab({super.key}) extends StatelessWidget { class const ProfileTab({super.key}) extends StatelessWidget {
@override @override
@ -20,7 +24,7 @@ class const ProfileTab({super.key}) extends StatelessWidget {
return TabScreen( return TabScreen(
title: l10n.shellTabProfile, title: l10n.shellTabProfile,
actions: const [settings], actions: const [settings],
body: const DaysList(), body: const DaysList(header: _Progress(daysHeading: true)),
); );
} }
return DefaultTabController( return DefaultTabController(
@ -34,7 +38,51 @@ class const ProfileTab({super.key}) extends StatelessWidget {
Tab(text: l10n.homePlans), 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/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/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.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/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/announce_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_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/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.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/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/feature/workout/widget/week_card.dart';
import 'package:halkyon_train_flutter/src/tabs/maps_tab.dart'; import 'package:halkyon_train_flutter/src/tabs/maps_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/record_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('52'), findsOneWidget);
expect(find.text('Female'), 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)', () { 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, " testWidgets("a workout's page shows its map, totals, splits and charts, "
'and where the map comes from', (tester) async { 'and where the map comes from', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30)); await saveRun(DateTime(2026, 10, 27, 7, 30));

View file

@ -109,4 +109,84 @@ void main() {
expect(week.longestDay, const Duration(hours: 1)); 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);
});
});
} }

View file

@ -1516,6 +1516,13 @@ Strava. What was found, 27 Oct:
activity, so it offers the plans and routes for it or for any alone, where one for activity, so it offers the plans and routes for it or for any alone, where one for
another activity had chosen it; while a recording is under way or waits to be saved, another activity had chosen it; while a recording is under way or waits to be saved,
the cards' Start waits, the bar leading back. the cards' Start waits, the bar leading back.
Profile opens on *Your body* (`BodyCard`: weight, resting and maximal heart rate, as
far as given, tapped opening the screen, which Settings keeps too), the last 12
weeks' time trained as bars (`WeeksCard`, `weeksBack`: this week chosen, a week
tapped showing its time, distance and workouts) and this calendar year by activity
(`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
developer's, 31 Oct). Every duration reads as the days' do (`hoursMinutes`).
## Consequences ## Consequences