fix(train): a day's page of its own, in cards, its night and Recharge drawn in place

The night's chart threw mid-paint where Polar's last phase starts at the
night's end or after it: its right edge was clamped between left + 1 and
the row's width, the lower bound then above the upper (an ArgumentError).
A throw mid-paint leaves the canvas moved, so the rows below and the charts
after them were drawn out of place, the skin temperature's among them. Each
phase now stays within its row, a pixel wide at least.

Nightly Recharge showed Polar's marks for a figure it has none of (-1 for
the recovery and the intervals, -100 for the nervous system's status, 0 for
a baseline not yet learnt) as the person's own. Recharge.known leaves them
out, on the page and in the days' list; the figures are rows, each with the
person's usual below it, and Polar's explanation of them (the status from
-10 to +10, what each measures) is behind halkyon_ui's question mark.

The page is a screen of its own over the tabs, without the bar and with the
way back at its top left, its parts in cards (the developer's review of
3 Nov 2026; D60 had it inside Profile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 21:36:34 +01:00
commit ddca423b8a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 1768 additions and 360 deletions

View file

@ -1,19 +1,13 @@
{
"@@locale": "de",
"appTitle": "Halkyon Train",
"launchProduct": "Train",
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Trainings liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"homeEmptyTitle": "Noch keine Trainings",
"homeEmptyHint": "Trainings, die du auf deinem Telefon aufzeichnest, erscheinen hier.",
"accountTitle": "Konto",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über Halkyon Train",
"homeRecordHint": "Zeichne eins im Tab Aufzeichnen auf.",
"recordBack": "Zurück zur Aufzeichnung",
"activityRunning": "Laufen",
@ -60,7 +54,6 @@
"recordingCutOffMessage": "Eine Aufzeichnung von {start} wurde abgebrochen. Bis {end} speichern oder verwerfen?",
"recordingNotificationChannel": "Aufzeichnung",
"recordingNotificationPaused": "{activity} · pausiert",
"workoutName": "{when, select, morning_strength{Krafttraining am Morgen} afternoon_strength{Krafttraining am Nachmittag} evening_strength{Krafttraining am Abend} night_strength{Krafttraining in der Nacht} morning_running{Morgenlauf} morning_walking{Morgenspaziergang} morning_hiking{Morgenwanderung} morning_cycling{Morgenradfahrt} morning_indoorRowing{Rudern am Morgen} afternoon_running{Nachmittagslauf} afternoon_walking{Nachmittagsspaziergang} afternoon_hiking{Nachmittagswanderung} afternoon_cycling{Nachmittagsradfahrt} afternoon_indoorRowing{Rudern am Nachmittag} evening_running{Abendlauf} evening_walking{Abendspaziergang} evening_hiking{Abendwanderung} evening_cycling{Abendradfahrt} evening_indoorRowing{Rudern am Abend} night_running{Nachtlauf} night_walking{Nachtspaziergang} night_hiking{Nachtwanderung} night_cycling{Nachtradfahrt} night_indoorRowing{Rudern in der Nacht} other{Training}}",
"workoutLocked": "Gesperrtes Training",
"workoutLockedHint": "Dieses Gerät hat den Schlüssel nicht, mit dem es versiegelt ist.",
@ -95,7 +88,6 @@
"workoutMapSourceAbout": "Was {name} speichert",
"workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.",
"workoutNotFound": "Dieses Training gibt es nicht mehr.",
"recordHeartRate": "Herzfrequenz",
"recordNoStrap": "Kein Brustgurt",
"recordHeartLoop": "Vom Polar Loop",
@ -115,7 +107,6 @@
"workoutMaxHeartRate": "höchste Herzfrequenz",
"workoutBpmValue": "{bpm} bpm",
"workoutHeartRateChart": "Herzfrequenz",
"recordRower": "Rudergerät",
"recordNoRower": "Wähle dein Rudergerät",
"recordRowerRefused": "Train braucht Geräte in der Nähe, um von deinem Rudergerät aufzuzeichnen. Du kannst das in den Systemeinstellungen erlauben.",
@ -144,7 +135,6 @@
"workoutPowerChart": "Leistung",
"recordAutoPauseRowingHint": "Wenn du aufhörst zu rudern, weiter mit dem nächsten Schlag",
"recordingMetres": "Meter",
"healthTitle": "Health Connect",
"healthHint": "Trainings mit anderen Apps auf diesem Telefon teilen",
"healthUnavailable": "Health Connect ist auf diesem Telefon nicht da. Ab Android 14 ist es eingebaut, davor eine eigene App.",
@ -159,7 +149,6 @@
"healthRationaleWrite": "Train schreibt deine Trainings erst in Health Connect, wenn du es einschaltest: jedes mit Strecke, Herzfrequenz, Tempo, Leistung, Distanz und Anstieg, damit andere Apps auf diesem Telefon sie nutzen können. Bearbeitest oder löschst du ein Training in Train, geschieht das dort auch.",
"healthRationaleRead": "Train liest die Trainings anderer Apps erst, wenn du es einschaltest, und nur von den Apps, die du wählst: Ihre Herzfrequenz, Distanz und Leistung kommen als Trainings in Train, mit deinem Schlüssel versiegelt, bevor sie mit deiner Instanz abgeglichen werden, die sie nicht lesen kann.",
"healthRationaleNothingElse": "Was Train dort liest, wird dir gezeigt und niemandem sonst, und für nichts anderes genutzt. Schaltest du einen Schalter aus oder nimmst eine Erlaubnis in Health Connect zurück, hört es auf.",
"healthRead": "Von anderen Apps lesen",
"healthReadHint": "Ihre Trainings kommen in Train, von den Apps, die du unten wählst",
"healthApps": "Apps",
@ -168,7 +157,6 @@
"workoutFrom": "Von {app}, über Health Connect",
"workoutBringTrack": "Strecke holen",
"workoutNoTrack": "Health Connect hat keine Strecke dazu gegeben.",
"profileTitle": "Dein Körper",
"profileHint": "Woraus dein Armband und deine Herzfrequenzzonen rechnen",
"profileIntro": "Jede Angabe ist freiwillig und mit deinem Schlüssel versiegelt, wie deine Trainings; deine Instanz kann sie nicht lesen.",
@ -203,7 +191,6 @@
"profileSave": "Speichern",
"profileSaved": "Gespeichert",
"profileInvalid": "Prüfe die markierten Angaben.",
"profileNeeded": "Braucht dein Band",
"loopTitle": "Polar Loop",
"loopHint": "Dein Band: Einrichtung, Akku und Uhrzeit",
@ -370,11 +357,15 @@
"dayRatingSomewhatWell": "Du hast eher gut geschlafen",
"dayRatingWell": "Du hast gut geschlafen",
"dayRecharge": "Nightly Recharge",
"dayRechargeLevel": "Erholung {level} von 6",
"dayAnsStatus": "Autonomes Nervensystem {value}, von −10 bis +10",
"dayNightHeart": "Herzfrequenz {bpm} S/min, sonst {usual} S/min",
"dayHrv": "Herzfrequenzvariabilität {ms} ms, sonst {usual} ms",
"dayBreathing": "{rate} Atemzüge pro Minute",
"dayRecovery": "Erholung",
"dayRecoveryOf": "{level} von 6",
"dayAns": "Autonomes Nervensystem",
"dayHrvName": "Herzfrequenzvariabilität",
"dayBreathingName": "Atmung",
"dayPerMinute": "{rate} pro Minute",
"dayMs": "{ms} ms",
"dayUsual": "sonst {value}",
"dayRechargeHelp": "Nightly Recharge ist Polars Maß dafür, wie sich dein Körper in den ersten Stunden des Schlafs erholt hat, gemessen an deinem eigenen Üblichen. Die Erholung reicht von 1 bis 6. Der Status des autonomen Nervensystems reicht von −10 bis +10: über 0 hat es sich besser beruhigt als sonst. Die Herzfrequenzvariabilität ist, wie sehr die Zeit zwischen den Herzschlägen schwankt; höher als sonst spricht meist für eine bessere Erholung. Die Atmung zählt deine Atemzüge pro Minute. Was das Band noch nicht kennt, fehlt.",
"daySkin": "Hauttemperatur",
"dayCelsius": "{value} °C",
"daySkinDeviation": "{value} °C gegenüber deinen Nächten sonst",
@ -582,7 +573,6 @@
"bodyResting": "Ruhepuls {bpm}",
"bodyMax": "Maximalpuls {bpm}",
"homeToday": "Heute",
"equipmentTitle": "Ausrüstung",
"equipmentHint": "Wo du trainierst und was es dort gibt",
"equipmentEmptyTitle": "Noch keine Orte",
@ -636,7 +626,6 @@
"plateNoChange": "Nichts ändert sich",
"plateMoves": "{count, plural, =0{Keine Scheibe bewegt sich} =1{1 Scheibe je Seite bewegt sich} other{{count} Scheiben je Seite bewegen sich}}",
"plateSet": "Satz {number}",
"exerciseName": "{id, select, backSquat{Kniebeuge} frontSquat{Frontkniebeuge} gobletSquat{Goblet Squat} kettlebellGobletSquat{Goblet Squat mit Kettlebell} bodyweightSquat{Kniebeuge ohne Gewicht} lunge{Ausfallschritt} bulgarianSplitSquat{Bulgarische Kniebeuge} bodyweightLunge{Ausfallschritt ohne Gewicht} deadlift{Kreuzheben} romanianDeadlift{Rumänisches Kreuzheben} trapBarDeadlift{Kreuzheben mit Trap Bar} dumbbellRomanianDeadlift{Rumänisches Kreuzheben mit Kurzhanteln} kettlebellSwing{Kettlebell Swing} hipThrust{Hip Thrust} gluteBridge{Glute Bridge} benchPress{Bankdrücken} inclineBenchPress{Schrägbankdrücken} dumbbellBenchPress{Kurzhantel-Bankdrücken} pushUp{Liegestütz} dip{Dip} overheadPress{Schulterdrücken} dumbbellShoulderPress{Kurzhantel-Schulterdrücken} pikePushUp{Pike-Liegestütz} barbellRow{Langhantelrudern} dumbbellRow{Einarmiges Kurzhantelrudern} cableRow{Rudern am Kabelzug} invertedRow{Umgekehrtes Rudern} bandPullApart{Band Pull-Apart} facePull{Face Pull} pullUp{Klimmzug} chinUp{Klimmzug im Untergriff} latPulldown{Latzug} bandPulldown{Latzug mit Band} barbellCurl{Langhantel-Curl} ezBarCurl{SZ-Curl} dumbbellCurl{Kurzhantel-Curl} hammerCurl{Hammer-Curl} tricepsPushdown{Trizepsdrücken am Kabel} skullCrusher{French Press} overheadTricepsExtension{Trizepsstrecken über Kopf} benchDip{Dip an der Bank} lateralRaise{Seitheben} cableLateralRaise{Seitheben am Kabel} calfRaise{Wadenheben} plank{Unterarmstütz} sidePlank{Seitstütz} hangingLegRaise{Beinheben im Hang} crunch{Crunch} farmersCarry{Farmer’s Walk} other{Übung}}",
"exerciseNeed": "{need, select, barbell{eine Langhantel} ezBar{eine SZ-Stange} trapBar{eine Trap Bar} dumbbells{Kurzhanteln} kettlebell{eine Kettlebell} bench{eine Bank} rack{ein Rack} pullUpBar{eine Klimmzugstange} dipBars{eine Dipstation} cableMachine{einen Kabelzug} bands{Widerstandsbänder} other{etwas}}",
"exercisePattern": "{pattern, select, squat{Kniebeugen} lunge{Ausfallschritte} hinge{Hüftstreckung} horizontalPush{Drücken} verticalPush{Drücken über Kopf} horizontalPull{Rudern} verticalPull{Klimmzüge und Latzug} curl{Curls} extension{Trizeps} raise{Heben} calf{Waden} core{Rumpf} carry{Tragen} other{Andere}}",

File diff suppressed because it is too large Load diff

View file

@ -2242,35 +2242,59 @@ abstract class AppLocalizations {
/// **'Nightly Recharge'**
String get dayRecharge;
/// No description provided for @dayRechargeLevel.
/// No description provided for @dayRecovery.
///
/// In en, this message translates to:
/// **'Recovery {level} of 6'**
String dayRechargeLevel(int level);
/// **'Recovery'**
String get dayRecovery;
/// No description provided for @dayAnsStatus.
/// No description provided for @dayRecoveryOf.
///
/// In en, this message translates to:
/// **'Autonomic nervous system {value}, from −10 to +10'**
String dayAnsStatus(String value);
/// **'{level} of 6'**
String dayRecoveryOf(int level);
/// No description provided for @dayNightHeart.
/// No description provided for @dayAns.
///
/// In en, this message translates to:
/// **'Heart rate {bpm} bpm, your usual {usual} bpm'**
String dayNightHeart(int bpm, int usual);
/// **'Autonomic nervous system'**
String get dayAns;
/// No description provided for @dayHrv.
/// No description provided for @dayHrvName.
///
/// In en, this message translates to:
/// **'Heart rate variability {ms} ms, your usual {usual} ms'**
String dayHrv(int ms, int usual);
/// **'Heart rate variability'**
String get dayHrvName;
/// No description provided for @dayBreathing.
/// No description provided for @dayBreathingName.
///
/// In en, this message translates to:
/// **'Breathing {rate} a minute'**
String dayBreathing(String rate);
/// **'Breathing'**
String get dayBreathingName;
/// No description provided for @dayPerMinute.
///
/// In en, this message translates to:
/// **'{rate} a minute'**
String dayPerMinute(String rate);
/// No description provided for @dayMs.
///
/// In en, this message translates to:
/// **'{ms} ms'**
String dayMs(int ms);
/// Below a figure of the night: the person's own usual, as Polar learnt it.
///
/// In en, this message translates to:
/// **'usually {value}'**
String dayUsual(String value);
/// No description provided for @dayRechargeHelp.
///
/// In en, this message translates to:
/// **'Nightly Recharge is Polar\'s measure of how your body recovered in the first hours of sleep, against your own usual. Recovery runs from 1 to 6. The autonomic nervous system\'s status runs from −10 to +10: above 0, it settled better than usual. Heart rate variability is how much the time between heartbeats varies; higher than usual tends to mean a better recovery. Breathing counts your breaths a minute. A figure the band has none of yet is left out.'**
String get dayRechargeHelp;
/// No description provided for @daySkin.
///

View file

@ -1340,29 +1340,40 @@ class AppLocalizationsDe extends AppLocalizations {
String get dayRecharge => 'Nightly Recharge';
@override
String dayRechargeLevel(int level) {
return 'Erholung $level von 6';
String get dayRecovery => 'Erholung';
@override
String dayRecoveryOf(int level) {
return '$level von 6';
}
@override
String dayAnsStatus(String value) {
return 'Autonomes Nervensystem $value, von −10 bis +10';
String get dayAns => 'Autonomes Nervensystem';
@override
String get dayHrvName => 'Herzfrequenzvariabilität';
@override
String get dayBreathingName => 'Atmung';
@override
String dayPerMinute(String rate) {
return '$rate pro Minute';
}
@override
String dayNightHeart(int bpm, int usual) {
return 'Herzfrequenz $bpm S/min, sonst $usual S/min';
String dayMs(int ms) {
return '$ms ms';
}
@override
String dayHrv(int ms, int usual) {
return 'Herzfrequenzvariabilität $ms ms, sonst $usual ms';
String dayUsual(String value) {
return 'sonst $value';
}
@override
String dayBreathing(String rate) {
return '$rate Atemzüge pro Minute';
}
String get dayRechargeHelp =>
'Nightly Recharge ist Polars Maß dafür, wie sich dein Körper in den ersten Stunden des Schlafs erholt hat, gemessen an deinem eigenen Üblichen. Die Erholung reicht von 1 bis 6. Der Status des autonomen Nervensystems reicht von −10 bis +10: über 0 hat es sich besser beruhigt als sonst. Die Herzfrequenzvariabilität ist, wie sehr die Zeit zwischen den Herzschlägen schwankt; höher als sonst spricht meist für eine bessere Erholung. Die Atmung zählt deine Atemzüge pro Minute. Was das Band noch nicht kennt, fehlt.';
@override
String get daySkin => 'Hauttemperatur';

View file

@ -1333,29 +1333,40 @@ class AppLocalizationsEn extends AppLocalizations {
String get dayRecharge => 'Nightly Recharge';
@override
String dayRechargeLevel(int level) {
return 'Recovery $level of 6';
String get dayRecovery => 'Recovery';
@override
String dayRecoveryOf(int level) {
return '$level of 6';
}
@override
String dayAnsStatus(String value) {
return 'Autonomic nervous system $value, from −10 to +10';
String get dayAns => 'Autonomic nervous system';
@override
String get dayHrvName => 'Heart rate variability';
@override
String get dayBreathingName => 'Breathing';
@override
String dayPerMinute(String rate) {
return '$rate a minute';
}
@override
String dayNightHeart(int bpm, int usual) {
return 'Heart rate $bpm bpm, your usual $usual bpm';
String dayMs(int ms) {
return '$ms ms';
}
@override
String dayHrv(int ms, int usual) {
return 'Heart rate variability $ms ms, your usual $usual ms';
String dayUsual(String value) {
return 'usually $value';
}
@override
String dayBreathing(String rate) {
return 'Breathing $rate a minute';
}
String get dayRechargeHelp =>
'Nightly Recharge is Polar\'s measure of how your body recovered in the first hours of sleep, against your own usual. Recovery runs from 1 to 6. The autonomic nervous system\'s status runs from −10 to +10: above 0, it settled better than usual. Heart rate variability is how much the time between heartbeats varies; higher than usual tends to mean a better recovery. Breathing counts your breaths a minute. A figure the band has none of yet is left out.';
@override
String get daySkin => 'Skin temperature';

View file

@ -1,7 +1,7 @@
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// The app's locations (ADR 0030, D60): the five tabs, a workout's page in
/// Home and a day's in Profile, and the editors and the recording over the
/// Home, and a day's page, the editors and the recording over the
/// tabs, and Settings.
abstract final class TrainPaths() {
/// Home, where the app opens: the workouts.
@ -22,8 +22,8 @@ abstract final class TrainPaths() {
/// A workout's page, in Home.
static String workout(String id) => '/workouts/$id';
/// The page of the day [date], in Profile, by the band's name for it.
static String day(DateTime date) => '/profile/days/${dayKey(date)}';
/// The page of the day [date], over the tabs, by the band's name for it.
static String day(DateTime date) => '/days/${dayKey(date)}';
/// A route, to plan, over the tabs: the stored one [id], or a new one.
static String route([String? id]) => '/routes/${id ?? 'new'}';

View file

@ -76,15 +76,6 @@ GoRouter buildTrainRouter() => GoRouter(
GoRoute(
path: TrainPaths.profile,
builder: (context, state) => const ProfileTab(),
routes: [
GoRoute(
path: 'days/:date',
builder: (context, state) => dayPage(
context,
dateOfKey(state.pathParameters['date'])!,
),
),
],
),
],
),
@ -106,6 +97,13 @@ GoRouter buildTrainRouter() => GoRouter(
id: _stored(state),
),
),
// A day's page over the tabs, without the bar, its way back at its top
// left (the developer's review of 3 Nov 2026).
GoRoute(
path: '/days/:date',
builder: (context, state) =>
dayPage(context, dateOfKey(state.pathParameters['date'])!),
),
GoRoute(
path: TrainPaths.recording,
builder: (context, state) => const RecordingScreen(),

View file

@ -36,6 +36,40 @@ final class const Recharge({
final int? baselineBreathInterval,
final int? baselineBreathIntervalSd,
}) with Equatable {
/// The figures as they can be shown: Polar marks a figure it has none of
/// with a value out of its range (-1 for the recovery and the intervals,
/// -100 for the nervous system's status, 0 for a baseline not yet
/// learnt), which were shown as the person's own until 3 Nov 2026.
Recharge get known {
int? interval(int? ms) => ms != null && ms > 0 ? ms : null;
int? deviation(int? ms) => ms != null && ms >= 0 ? ms : null;
final level = switch (recovery) {
final level? when level >= 1 && level <= 6 => level,
_ => null,
};
return Recharge(
ansStatus: switch (ansStatus) {
final status? when status >= -10 && status <= 10 => status,
_ => null,
},
recovery: level,
recoverySubLevel: level == null ? null : deviation(recoverySubLevel),
ansRate: switch (ansRate) {
final rate? when rate >= 1 => rate,
_ => null,
},
rri: interval(rri),
rmssd: interval(rmssd),
breathInterval: interval(breathInterval),
baselineRri: interval(baselineRri),
baselineRriSd: deviation(baselineRriSd),
baselineRmssd: interval(baselineRmssd),
baselineRmssdSd: deviation(baselineRmssdSd),
baselineBreathInterval: interval(baselineBreathInterval),
baselineBreathIntervalSd: deviation(baselineBreathIntervalSd),
);
}
/// The night's mean heart rate, from its mean beat-to-beat interval.
int? get heartRate => switch (rri) {
final ms? when ms > 0 => (60000 / ms).round(),

View file

@ -14,7 +14,8 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the page of the day [date], in Profile (D60).
/// Opens the page of the day [date], over the tabs (the developer's review
/// of 3 Nov 2026; in Profile until then, D60).
Future<void> openDayScreen(BuildContext context, DateTime date) =>
context.push(TrainPaths.day(date));
@ -27,83 +28,149 @@ Widget dayPage(BuildContext context, DateTime date) {
);
}
/// A day's page (ADR 0030, D37): its figures and its time in each class,
/// its heart rate as a chart, the night that ended it with its phases and
/// rating, its Nightly Recharge and its skin temperature.
/// A day's page (ADR 0030, D37), a screen of its own over the tabs with the
/// way back at its top left, grouped in cards (D63): its figures and its
/// time in each class, its heart rate as a chart, the night that ended it
/// with its phases and rating, its Nightly Recharge, Polar's explanation of
/// it behind a question mark, and its skin temperature.
class const DayScreen({required final DateTime date, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
return Scaffold(
appBar: AppBar(title: Text(dayTitle(locale, date))),
body: BlocBuilder<DayCubit, DayState>(
builder: (context, state) {
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final day = state.day;
final night = state.night;
final sections = [
if (day != null) _Figures(day),
if (day != null && day.times.isNotEmpty) _Times(day),
if (state.heart.any((run) => run.mean != null)) _Heart(state.heart),
if (night != null) _Night(night),
if (day?.recharge case final recharge?) _Recharge(recharge),
if (day != null && day.skin.length > 1) _Skin(day.skin),
];
if (sections.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(l10n.dayNothing, textAlign: TextAlign.center),
final theme = Theme.of(context);
return TopGradient(
background: theme.colorScheme.surfaceContainerLow,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(dayTitle(locale, date)),
),
body: BlocBuilder<DayCubit, DayState>(
builder: (context, state) {
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final day = state.day;
final night = state.night;
final recharge = day?.recharge?.known;
final sections = [
if (day != null)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Figures(day),
if (day.times.isNotEmpty) ...[
const SizedBox(height: 16),
_Times(day),
],
],
),
if (state.heart.any((run) => run.mean != null))
_Heart(state.heart),
if (night != null) _Night(night),
if (recharge != null && recharge != const Recharge())
_Recharge(recharge),
if (day != null && day.skin.length > 1) _Skin(day.skin),
];
if (sections.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(l10n.dayNothing, textAlign: TextAlign.center),
),
);
}
return ListView(
padding: EdgeInsets.only(
top: 4,
bottom: 12 + MediaQuery.paddingOf(context).bottom,
),
);
}
return ListView(
// Ending clear of the navigation bar floating over the foot.
padding: EdgeInsets.only(
top: 8,
bottom: 8 + MediaQuery.paddingOf(context).bottom,
),
children: [
for (final section in sections)
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: section,
children: [
for (final section in sections)
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
child: SectionCard(child: section),
),
),
),
),
],
);
},
],
);
},
),
),
);
}
}
/// A section's heading and its lines.
/// A section's heading, with the question mark to its explanation where
/// it has one, and its lines.
class const _Section({
required final String title,
required final List<Widget> children,
final String? help,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
Row(
children: [
Expanded(
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
if (help case final help?) HelpButton(help, title: title),
],
),
const SizedBox(height: 8),
...children,
],
);
}
/// A figure's name and its value, the person's usual below it where known.
class const _Figure(
final String _name,
final String _value, {
final String? usual,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(_name)),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_Line(_value),
if (usual case final usual?)
Text(
usual,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
],
),
);
}
}
class const _Line(final String _text) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
@ -244,6 +311,8 @@ class const _Night(final NightContent _night) extends StatelessWidget {
}
}
/// The night's Nightly Recharge, figures Polar has none of left out
/// ([Recharge.known]), each against the person's usual.
class const _Recharge(final Recharge _recharge) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -251,28 +320,46 @@ class const _Recharge(final Recharge _recharge) extends StatelessWidget {
final locale = Localizations.localeOf(context).toString();
final recharge = _recharge;
final usualHeart = switch (recharge.baselineRri) {
final ms? when ms > 0 => (60000 / ms).round(),
final ms? => (60000 / ms).round(),
_ => null,
};
final usualBreathing = switch (recharge.baselineBreathInterval) {
final ms? => decimal(locale, 60000 / ms),
_ => null,
};
return _Section(
title: l10n.dayRecharge,
help: l10n.dayRechargeHelp,
children: [
if (recharge.recovery case final level?)
_Line(l10n.dayRechargeLevel(level)),
_Figure(l10n.dayRecovery, l10n.dayRecoveryOf(level)),
if (recharge.ansStatus case final status?)
_Line(l10n.dayAnsStatus(decimal(locale, status, signed: true))),
if ((recharge.heartRate, usualHeart) case (
final bpm?,
final usual?,
))
_Line(l10n.dayNightHeart(bpm, usual)),
if ((recharge.rmssd, recharge.baselineRmssd) case (
final ms?,
final usual?,
))
_Line(l10n.dayHrv(ms, usual)),
if (recharge.breathInterval case final ms? when ms > 0)
_Line(l10n.dayBreathing(decimal(locale, 60000 / ms))),
_Figure(l10n.dayAns, decimal(locale, status, signed: true)),
if (recharge.heartRate case final bpm?)
_Figure(
l10n.dayHeartRate,
l10n.dayBpm(bpm),
usual: usualHeart == null
? null
: l10n.dayUsual(l10n.dayBpm(usualHeart)),
),
if (recharge.rmssd case final ms?)
_Figure(
l10n.dayHrvName,
l10n.dayMs(ms),
usual: switch (recharge.baselineRmssd) {
final usual? => l10n.dayUsual(l10n.dayMs(usual)),
null => null,
},
),
if (recharge.breathInterval case final ms?)
_Figure(
l10n.dayBreathingName,
l10n.dayPerMinute(decimal(locale, 60000 / ms)),
usual: usualBreathing == null
? null
: l10n.dayUsual(l10n.dayPerMinute(usualBreathing)),
),
],
);
}

View file

@ -15,7 +15,8 @@ List<String> dayFigures(AppLocalizations l10n, StoredDay stored) {
l10n.daysActive(hoursMinutes(l10n, active)),
if (stored.night?.asleep case final asleep?)
l10n.daysAsleep(hoursMinutes(l10n, asleep)),
if (day?.recharge?.recovery case final level?) l10n.daysRecharge(level),
if (day?.recharge?.known.recovery case final level?)
l10n.daysRecharge(level),
];
}

View file

@ -123,12 +123,20 @@ class _RowPainter(
}
final paint = Paint()..color = colour;
for (final span in _spans) {
final left = span.from.inSeconds / total * size.width;
final right = span.to.inSeconds / total * size.width;
canvas.drawRect(
Rect.fromLTRB(left, 0, right.clamp(left + 1, size.width), size.height),
paint,
// Within the row, a pixel wide at least: Polar's last phase starts at
// the night's very end, or after it, where clamping its right edge
// between left + 1 and the width threw (the lower bound above the
// upper), mid-paint, and left the rows below and the charts after
// them drawn out of place (found 3 Nov 2026).
final left = (span.from.inSeconds / total * size.width).clamp(
0.0,
size.width - 1,
);
final right = (span.to.inSeconds / total * size.width).clamp(
left + 1,
size.width,
);
canvas.drawRect(Rect.fromLTRB(left, 0, right, size.height), paint);
}
}

View file

@ -2825,6 +2825,8 @@ void main() {
'recovery': 4,
'rmssd': 48,
'baselineRmssd': 45,
// Polar's mark for a status it has none of.
'ansStatus': -100.0,
}),
),
);
@ -2862,16 +2864,36 @@ void main() {
await tester.tap(find.text('Tuesday, October 27'));
await tester.pumpAndSettle();
// Inside Profile, the tabs beside it (D60).
expect(find.byType(NavigationBar), findsOneWidget);
// A screen of its own over the tabs, the way back at its top left
// (the developer's review of 3 Nov 2026).
expect(find.byType(NavigationBar), findsNothing);
expect(find.byType(BackButton), findsOneWidget);
expect(find.text('Heart rate'), findsOneWidget);
expect(find.text('The night'), findsOneWidget);
expect(find.text('You slept well'), findsOneWidget);
await tester.scrollUntilVisible(find.text('Nightly Recharge'), 200);
expect(
find.text('Heart rate variability 48 ms, your usual 45 ms'),
findsOneWidget,
// The person's figures in rows, each with their usual; Polar's mark
// for no value shown nowhere, the explanation behind the question
// mark.
expect(find.text('Recovery'), findsOneWidget);
expect(find.text('4 of 6'), findsOneWidget);
expect(find.text('Heart rate variability'), findsOneWidget);
expect(find.text('48 ms'), findsOneWidget);
expect(find.text('usually 45 ms'), findsOneWidget);
expect(find.textContaining('100'), findsNothing);
expect(find.text('Autonomic nervous system'), findsNothing);
expect(find.textContaining('from −10 to +10'), findsNothing);
await tester.tap(
find.descendant(
of: find.ancestor(
of: find.text('Nightly Recharge'),
matching: find.byType(Row),
),
matching: find.byType(HelpButton),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('from −10 to +10'), findsOneWidget);
});
testWidgets("Home shows today's figures from the band, even before a "

View file

@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/night_chart.dart';
import 'package:material_ui/material_ui.dart';
void main() {
testWidgets("a phase starting at the night's end or after it paints "
'nothing out of its row rather than throwing mid-paint, which left '
'the rows and the charts below drawn out of place (3 Nov 2026)', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SizedBox(
width: 320,
child: NightChart(
phases: [
(Duration.zero, SleepState.light),
(Duration(hours: 2), SleepState.rem),
(Duration(hours: 3), SleepState.deep),
// Polar's last phase, at the night's very end, and one past
// it.
(Duration(hours: 4), SleepState.wake),
(Duration(hours: 5), SleepState.rem),
],
length: Duration(hours: 4),
),
),
),
),
);
expect(tester.takeException(), isNull);
});
}

View file

@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
void main() {
test("Polar's marks for no value (-1, -100, 0) are no value, the "
"person's own figures kept", () {
const fromBand = Recharge(
ansStatus: -100,
recovery: -1,
recoverySubLevel: -1,
rri: 1000,
rmssd: 69,
breathInterval: 4380,
baselineRri: 0,
baselineRmssd: -1,
);
expect(
fromBand.known,
const Recharge(rri: 1000, rmssd: 69, breathInterval: 4380),
);
});
test('figures in their ranges stay as they are', () {
const fromBand = Recharge(
ansStatus: -2.5,
recovery: 4,
recoverySubLevel: 2,
rmssd: 48,
baselineRmssd: 45,
);
expect(fromBand.known, fromBand);
});
}