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
11 changed files with 713 additions and 46 deletions
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue