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:
parent
f04388225b
commit
5cfab59bd2
15 changed files with 713 additions and 46 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(' · '),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue