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:
parent
adbb2c4f2f
commit
ddca423b8a
14 changed files with 1768 additions and 360 deletions
|
|
@ -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
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'}';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue