Merge branch 'train-days' into integration
# Conflicts: # apps/train/halkyon_train_flutter/lib/src/app/train_paths.dart # docs/adr/0030-halkyon-train.md
This commit is contained in:
commit
81adeb91cc
47 changed files with 25705 additions and 410 deletions
|
|
@ -48,6 +48,8 @@
|
|||
<uses-permission android:name="android.permission.health.READ_DISTANCE"/>
|
||||
<uses-permission android:name="android.permission.health.READ_POWER"/>
|
||||
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY"/>
|
||||
<!-- The weights other apps wrote, a scale's say (ADR 0030, D64). -->
|
||||
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.health.connect.client.records.PowerRecord
|
|||
import androidx.health.connect.client.records.Record
|
||||
import androidx.health.connect.client.records.SpeedRecord
|
||||
import androidx.health.connect.client.records.StepsCadenceRecord
|
||||
import androidx.health.connect.client.records.WeightRecord
|
||||
import androidx.health.connect.client.records.metadata.DataOrigin
|
||||
import androidx.health.connect.client.request.ChangesTokenRequest
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
|
|
@ -120,6 +121,7 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
|
|||
call.argument<Number>("end")!!.toLong(),
|
||||
),
|
||||
)
|
||||
"weights" -> result.success(weights((call.arguments as Number).toLong()))
|
||||
"route" -> route(call.arguments as String, result)
|
||||
"openSettings" -> {
|
||||
activity.startActivity(Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS))
|
||||
|
|
@ -238,6 +240,19 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
|
|||
.filter { it.metadata.dataOrigin.packageName != activity.packageName }
|
||||
.map { HealthRecords.sessionOf(it, appNameOf(it.metadata.dataOrigin.packageName)) }
|
||||
|
||||
/** Other apps' weights since [from] (D64): Health Connect's ID, the time in UTC milliseconds, kilograms and the app. */
|
||||
private suspend fun weights(from: Long): List<Map<String, Any?>> =
|
||||
readAll(WeightRecord::class, Instant.ofEpochMilli(from), Instant.now().plusSeconds(60), null)
|
||||
.filter { it.metadata.dataOrigin.packageName != activity.packageName }
|
||||
.map {
|
||||
mapOf(
|
||||
"id" to it.metadata.id,
|
||||
"at" to it.time.toEpochMilli(),
|
||||
"kilograms" to it.weight.inKilograms,
|
||||
"app" to it.metadata.dataOrigin.packageName,
|
||||
)
|
||||
}
|
||||
|
||||
/** What [app] recorded from [start] to [end]: its heart rate, distance and power. */
|
||||
private suspend fun samples(app: String, start: Long, end: Long): Map<String, Any?> {
|
||||
val (from, to) = Instant.ofEpochMilli(start) to Instant.ofEpochMilli(end)
|
||||
|
|
@ -343,6 +358,7 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
|
|||
HealthPermission.getReadPermission(PowerRecord::class),
|
||||
),
|
||||
"history" to setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY),
|
||||
"weight" to setOf(HealthPermission.getReadPermission(WeightRecord::class)),
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
@ -99,7 +92,6 @@
|
|||
"mapCredits": "Kartennachweise",
|
||||
"routeHelpTitle": "Eine Route planen",
|
||||
"workoutNotFound": "Dieses Training gibt es nicht mehr.",
|
||||
|
||||
"recordHeartRate": "Herzfrequenz",
|
||||
"recordNoStrap": "Kein Brustgurt",
|
||||
"recordHeartLoop": "Vom Polar Loop",
|
||||
|
|
@ -119,7 +111,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.",
|
||||
|
|
@ -148,7 +139,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.",
|
||||
|
|
@ -163,16 +153,17 @@
|
|||
"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",
|
||||
"healthReadWeight": "Mein Gewicht lesen",
|
||||
"healthReadWeightHint": "Was andere Apps an Gewicht schrieben, etwa die einer Waage, kommt in Train",
|
||||
"healthRationaleWeight": "Train liest das Gewicht, das andere Apps schrieben, erst, wenn du es einschaltest: Jeder Wert kommt einmal in Train, mit deinem Schlüssel versiegelt, bevor er mit deiner Instanz synchronisiert wird, die ihn nicht lesen kann.",
|
||||
"healthApps": "Apps",
|
||||
"healthAppsEmpty": "Noch keine App hat Trainings in Health Connect geschrieben.",
|
||||
"workoutFromList": "von {app}",
|
||||
"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.",
|
||||
|
|
@ -207,7 +198,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",
|
||||
|
|
@ -374,11 +364,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",
|
||||
|
|
@ -583,10 +577,19 @@
|
|||
"profileWorkouts": "{count, plural, =1{1 Training} other{{count} Trainings}}",
|
||||
"profileYearEmpty": "Dieses Jahr noch keine Trainings.",
|
||||
"bodyWeight": "{kg} kg",
|
||||
"weightTitle": "Gewicht",
|
||||
"weightAdd": "Gewicht eintragen",
|
||||
"weightAll": "Alle",
|
||||
"weightEmpty": "Trag dein Gewicht ein, um es hier zu verfolgen; das neueste ist auch das Gewicht deines Körpers.",
|
||||
"weightOverTime": "Im Verlauf",
|
||||
"weightField": "Gewicht",
|
||||
"weightInvalid": "Ein Gewicht zwischen 20 und 400 kg",
|
||||
"weightWhen": "Gemessen",
|
||||
"weightDelete": "Löschen",
|
||||
"weightFromHealth": "von {app}",
|
||||
"bodyResting": "Ruhepuls {bpm}",
|
||||
"bodyMax": "Maximalpuls {bpm}",
|
||||
"homeToday": "Heute",
|
||||
|
||||
"equipmentTitle": "Ausrüstung",
|
||||
"equipmentHint": "Wo du trainierst und was es dort gibt",
|
||||
"equipmentEmptyTitle": "Noch keine Orte",
|
||||
|
|
@ -640,7 +643,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
|
|
@ -1030,6 +1030,24 @@ abstract class AppLocalizations {
|
|||
/// **'Their workouts come into Train, from the apps you choose below'**
|
||||
String get healthReadHint;
|
||||
|
||||
/// No description provided for @healthReadWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Read my weight'**
|
||||
String get healthReadWeight;
|
||||
|
||||
/// No description provided for @healthReadWeightHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weights other apps wrote, a scale\'s say, come into Train'**
|
||||
String get healthReadWeightHint;
|
||||
|
||||
/// No description provided for @healthRationaleWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Train reads the weights other apps wrote only once you turn it on: each comes into Train once, sealed with your key before it\'s synced to your instance, which can\'t read it.'**
|
||||
String get healthRationaleWeight;
|
||||
|
||||
/// Over the apps that wrote workouts to Health Connect, each with its switch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -2266,35 +2284,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.
|
||||
///
|
||||
|
|
@ -3520,6 +3562,66 @@ abstract class AppLocalizations {
|
|||
/// **'{kg} kg'**
|
||||
String bodyWeight(String kg);
|
||||
|
||||
/// No description provided for @weightTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weight'**
|
||||
String get weightTitle;
|
||||
|
||||
/// No description provided for @weightAdd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add weight'**
|
||||
String get weightAdd;
|
||||
|
||||
/// No description provided for @weightAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All'**
|
||||
String get weightAll;
|
||||
|
||||
/// No description provided for @weightEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add your weight to follow it here; the latest is also your body\'s weight.'**
|
||||
String get weightEmpty;
|
||||
|
||||
/// No description provided for @weightOverTime.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Over time'**
|
||||
String get weightOverTime;
|
||||
|
||||
/// No description provided for @weightField.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weight'**
|
||||
String get weightField;
|
||||
|
||||
/// No description provided for @weightInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A weight between 20 and 400 kg'**
|
||||
String get weightInvalid;
|
||||
|
||||
/// No description provided for @weightWhen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Measured'**
|
||||
String get weightWhen;
|
||||
|
||||
/// No description provided for @weightDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get weightDelete;
|
||||
|
||||
/// A weight read from Health Connect: the package of the app that wrote it.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'from {app}'**
|
||||
String weightFromHealth(String app);
|
||||
|
||||
/// No description provided for @bodyResting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -577,6 +577,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get healthReadHint =>
|
||||
'Ihre Trainings kommen in Train, von den Apps, die du unten wählst';
|
||||
|
||||
@override
|
||||
String get healthReadWeight => 'Mein Gewicht lesen';
|
||||
|
||||
@override
|
||||
String get healthReadWeightHint =>
|
||||
'Was andere Apps an Gewicht schrieben, etwa die einer Waage, kommt in Train';
|
||||
|
||||
@override
|
||||
String get healthRationaleWeight =>
|
||||
'Train liest das Gewicht, das andere Apps schrieben, erst, wenn du es einschaltest: Jeder Wert kommt einmal in Train, mit deinem Schlüssel versiegelt, bevor er mit deiner Instanz synchronisiert wird, die ihn nicht lesen kann.';
|
||||
|
||||
@override
|
||||
String get healthApps => 'Apps';
|
||||
|
||||
|
|
@ -1352,29 +1363,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';
|
||||
|
|
@ -2119,6 +2141,39 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$kg kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String get weightTitle => 'Gewicht';
|
||||
|
||||
@override
|
||||
String get weightAdd => 'Gewicht eintragen';
|
||||
|
||||
@override
|
||||
String get weightAll => 'Alle';
|
||||
|
||||
@override
|
||||
String get weightEmpty =>
|
||||
'Trag dein Gewicht ein, um es hier zu verfolgen; das neueste ist auch das Gewicht deines Körpers.';
|
||||
|
||||
@override
|
||||
String get weightOverTime => 'Im Verlauf';
|
||||
|
||||
@override
|
||||
String get weightField => 'Gewicht';
|
||||
|
||||
@override
|
||||
String get weightInvalid => 'Ein Gewicht zwischen 20 und 400 kg';
|
||||
|
||||
@override
|
||||
String get weightWhen => 'Gemessen';
|
||||
|
||||
@override
|
||||
String get weightDelete => 'Löschen';
|
||||
|
||||
@override
|
||||
String weightFromHealth(String app) {
|
||||
return 'von $app';
|
||||
}
|
||||
|
||||
@override
|
||||
String bodyResting(int bpm) {
|
||||
return 'Ruhepuls $bpm';
|
||||
|
|
|
|||
|
|
@ -573,6 +573,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get healthReadHint =>
|
||||
'Their workouts come into Train, from the apps you choose below';
|
||||
|
||||
@override
|
||||
String get healthReadWeight => 'Read my weight';
|
||||
|
||||
@override
|
||||
String get healthReadWeightHint =>
|
||||
'Weights other apps wrote, a scale\'s say, come into Train';
|
||||
|
||||
@override
|
||||
String get healthRationaleWeight =>
|
||||
'Train reads the weights other apps wrote only once you turn it on: each comes into Train once, sealed with your key before it\'s synced to your instance, which can\'t read it.';
|
||||
|
||||
@override
|
||||
String get healthApps => 'Apps';
|
||||
|
||||
|
|
@ -1345,29 +1356,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';
|
||||
|
|
@ -2107,6 +2129,39 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '$kg kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String get weightTitle => 'Weight';
|
||||
|
||||
@override
|
||||
String get weightAdd => 'Add weight';
|
||||
|
||||
@override
|
||||
String get weightAll => 'All';
|
||||
|
||||
@override
|
||||
String get weightEmpty =>
|
||||
'Add your weight to follow it here; the latest is also your body\'s weight.';
|
||||
|
||||
@override
|
||||
String get weightOverTime => 'Over time';
|
||||
|
||||
@override
|
||||
String get weightField => 'Weight';
|
||||
|
||||
@override
|
||||
String get weightInvalid => 'A weight between 20 and 400 kg';
|
||||
|
||||
@override
|
||||
String get weightWhen => 'Measured';
|
||||
|
||||
@override
|
||||
String get weightDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String weightFromHealth(String app) {
|
||||
return 'from $app';
|
||||
}
|
||||
|
||||
@override
|
||||
String bodyResting(int bpm) {
|
||||
return 'resting $bpm bpm';
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_tex
|
|||
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/bloc/strength_plans_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/bloc/weights_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -106,6 +107,12 @@ class _TrainPersonAppState() extends State<TrainPersonApp> {
|
|||
create: (context) =>
|
||||
OperatingCubit(AppScope.of(context).instanceOperators),
|
||||
),
|
||||
// Where sync stands: Settings' card and the dot on the cog, as
|
||||
// Learn's and Todo's.
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
SyncStatusBloc(PersonScope.of<TrainPerson>(context).sync),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
|
|
@ -132,6 +139,13 @@ class _TrainPersonAppState() extends State<TrainPersonApp> {
|
|||
return PlacesCubit(train.places, train.chosenPlace)..open();
|
||||
},
|
||||
),
|
||||
// Their weights, which Progress and Your body share (D64).
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
return WeightsCubit(train.weights, train.profile)..open();
|
||||
},
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_re
|
|||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_session_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/sync_strength_plan_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/sync_weight_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
|
@ -75,6 +77,10 @@ class TrainPerson(
|
|||
/// Whose they are.
|
||||
final SignedIn person, {
|
||||
|
||||
/// What sends their changes and brings their other devices' (D14):
|
||||
/// Settings' Sync card and the cog's dot follow it.
|
||||
required final SyncService sync,
|
||||
|
||||
/// Their workouts, sealed and synced.
|
||||
required final WorkoutRepository workouts,
|
||||
|
||||
|
|
@ -98,6 +104,10 @@ class TrainPerson(
|
|||
/// The place last chosen on this device (ADR 0034, D4).
|
||||
required final ChosenPlaceStore chosenPlace,
|
||||
|
||||
/// Their weights (D64), entered or read from Health Connect, sealed and
|
||||
/// synced.
|
||||
required final WeightRepository weights,
|
||||
|
||||
/// Their strength plans (ADR 0035), sealed and synced.
|
||||
required final StrengthPlanRepository strengthPlans,
|
||||
|
||||
|
|
@ -440,6 +450,7 @@ Future<TrainPerson> _openPersonData(
|
|||
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
|
||||
final routes = RouteRepository(SyncRouteGateway(db, sync), keys);
|
||||
final places = PlaceRepository(SyncPlaceGateway(db, sync), keys);
|
||||
final weights = WeightRepository(SyncWeightGateway(db, sync), keys);
|
||||
final strengthPlans = StrengthPlanRepository(
|
||||
SyncStrengthPlanGateway(db, sync),
|
||||
keys,
|
||||
|
|
@ -459,6 +470,7 @@ Future<TrainPerson> _openPersonData(
|
|||
AndroidHealthPlatform(),
|
||||
HealthStore(File(p.join(folder.path, 'health_connect.json'))),
|
||||
workouts,
|
||||
weights: weights,
|
||||
)..start())
|
||||
: null;
|
||||
final recorder = Platform.isAndroid ? AndroidRecorderPlatform() : null;
|
||||
|
|
@ -496,6 +508,7 @@ Future<TrainPerson> _openPersonData(
|
|||
: AppLifecycleListener(onResume: () => unawaited(keptMusic.sync()));
|
||||
return TrainPerson(
|
||||
person,
|
||||
sync: sync,
|
||||
workouts: workouts,
|
||||
profile: profile,
|
||||
days: days,
|
||||
|
|
@ -503,6 +516,7 @@ Future<TrainPerson> _openPersonData(
|
|||
routes: routes,
|
||||
places: places,
|
||||
chosenPlace: ChosenPlaceStore(File(p.join(folder.path, 'place.json'))),
|
||||
weights: weights,
|
||||
strengthPlans: strengthPlans,
|
||||
strengthSession: StrengthSessionStore(
|
||||
File(p.join(folder.path, 'strength_session.json')),
|
||||
|
|
@ -552,6 +566,7 @@ Future<TrainPerson> _openPersonData(
|
|||
await plans.dispose();
|
||||
await routes.dispose();
|
||||
await places.dispose();
|
||||
await weights.dispose();
|
||||
await strengthPlans.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
|
||||
|
||||
/// The app's locations (ADR 0030, D60): the five tabs, a day's page in
|
||||
/// Profile, and a workout's page (D63), the editors and the recording over
|
||||
/// the tabs, and Settings.
|
||||
/// The app's locations (ADR 0030, D60): the five tabs, a workout's page
|
||||
/// (D63) and a day's page (D64), the editors and the recording over the
|
||||
/// tabs, and Settings.
|
||||
abstract final class TrainPaths() {
|
||||
/// Home, where the app opens: the workouts.
|
||||
static const home = '/';
|
||||
|
|
@ -22,8 +22,9 @@ abstract final class TrainPaths() {
|
|||
/// A workout's page, in Home, over the tabs (D63).
|
||||
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 (D64), 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'}';
|
||||
|
|
|
|||
|
|
@ -84,15 +84,6 @@ GoRouter _router(GlobalKey<NavigatorState> root) => GoRouter(
|
|||
GoRoute(
|
||||
path: TrainPaths.profile,
|
||||
builder: (context, state) => const ProfileTab(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'days/:date',
|
||||
builder: (context, state) => dayPage(
|
||||
context,
|
||||
dateOfKey(state.pathParameters['date'])!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -114,6 +105,13 @@ GoRouter _router(GlobalKey<NavigatorState> root) => 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(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A tile's [title] with its explanation, [help], behind halkyon_ui's
|
||||
/// [HelpButton] beside it rather than under it (ADR 0030, D64): what
|
||||
/// explains is one tap away, what is the person's own stays in sight.
|
||||
class const HelpTitle(
|
||||
final String title, {
|
||||
required final String help,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Flexible(child: Text(title)),
|
||||
HelpButton(help, title: title),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (D64): 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(
|
||||
|
|
@ -207,6 +274,10 @@ class const _Heart(final List<HeartRun> _runs) extends StatelessWidget {
|
|||
(x: run.at.inSeconds.toDouble(), value: mean.toDouble()),
|
||||
],
|
||||
label: (value) => l10n.dayBpm(value.round()),
|
||||
// Each run's start is the band's time of day.
|
||||
xLabel: (seconds) => DateFormat.Hm(
|
||||
Localizations.localeOf(context).toString(),
|
||||
).format(DateTime(2000).add(Duration(seconds: seconds.round()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -244,6 +315,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 +324,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,10 +69,23 @@ class HealthCubit(final HealthConnect _health) extends Cubit<HealthState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Turns reading other apps' weights [on] (D64), Health Connect asking
|
||||
/// first.
|
||||
Future<void> setReadingWeights({required bool on}) async {
|
||||
emit(state.copyWith(asking: on, refused: false));
|
||||
final reads = await _health.setReadingWeights(on: on);
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(asking: false, refused: on && !reads));
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes [app]'s sessions, or no longer.
|
||||
Future<void> setApp(String app, {required bool on}) =>
|
||||
_health.setApp(app, on: on);
|
||||
|
||||
/// Whether weights read have somewhere to go on this phone.
|
||||
bool get readsWeightsHere => _health.weights != null;
|
||||
|
||||
/// Opens Health Connect's settings.
|
||||
Future<void> openSettings() => _health.platform.openSettings();
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,26 @@ class AndroidHealthPlatform() implements HealthPlatform {
|
|||
return route == null ? null : routeOf(route);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<HealthWeight>> weights({required DateTime from}) async => [
|
||||
for (final weight in (await _call<List<Object?>>(
|
||||
'weights',
|
||||
from.millisecondsSinceEpoch,
|
||||
))!)
|
||||
if (weight case {
|
||||
'id': final String id,
|
||||
'at': final int at,
|
||||
'kilograms': final num kilograms,
|
||||
'app': final String app,
|
||||
})
|
||||
HealthWeight(
|
||||
id: id,
|
||||
at: DateTime.fromMillisecondsSinceEpoch(at, isUtc: true),
|
||||
kilograms: kilograms.toDouble(),
|
||||
app: app,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> openSettings() => _call<void>('openSettings');
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import 'package:halkyon_train_flutter/src/feature/health/data/health_import.dart
|
|||
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_records.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
|
|
@ -19,14 +21,18 @@ final _always = DateTime.utc(2000);
|
|||
/// A person's workouts and Health Connect on this phone (ADR 0030, D31):
|
||||
/// with writing on, every workout of Train's written there, and written
|
||||
/// again or deleted as it changes; with reading on, the sessions of the
|
||||
/// apps the person chose brought in as workouts. Train's own store stays
|
||||
/// what counts.
|
||||
/// apps the person chose brought in as workouts; with reading weights on,
|
||||
/// other apps' weights brought in to [weights] (D64). Train's own store
|
||||
/// stays what counts.
|
||||
class HealthConnect(
|
||||
/// Health Connect itself.
|
||||
final HealthPlatform platform,
|
||||
final HealthStore _store,
|
||||
final WorkoutRepository _workouts,
|
||||
) {
|
||||
final WorkoutRepository _workouts, {
|
||||
|
||||
/// Where weights read come in; none, none are read.
|
||||
final WeightRepository? weights,
|
||||
}) {
|
||||
late HealthSettings _settings = _store.read();
|
||||
late final _following = CoalescingRunner(
|
||||
_follow,
|
||||
|
|
@ -103,6 +109,24 @@ class HealthConnect(
|
|||
return reads;
|
||||
}
|
||||
|
||||
/// Turns reading other apps' weights [on] (D64), asking Health Connect
|
||||
/// first; whether it is on.
|
||||
Future<bool> setReadingWeights({required bool on}) async {
|
||||
if (!on) {
|
||||
_update(_settings.copyWith(readsWeights: false));
|
||||
return false;
|
||||
}
|
||||
final granted = await platform.ask({HealthAccess.weight});
|
||||
final reads = granted.contains(HealthAccess.weight);
|
||||
_update(
|
||||
_settings.copyWith(readsWeights: reads, weightsRefused: !reads),
|
||||
);
|
||||
if (reads) {
|
||||
await _reading.run();
|
||||
}
|
||||
return reads;
|
||||
}
|
||||
|
||||
/// Takes the sessions of [app], or no longer; turned on, those it wrote
|
||||
/// before come in too.
|
||||
Future<void> setApp(String app, {required bool on}) async {
|
||||
|
|
@ -249,9 +273,67 @@ class HealthConnect(
|
|||
}
|
||||
}
|
||||
|
||||
/// Reads what other apps wrote: their sessions, then their weights.
|
||||
Future<void> _read() async {
|
||||
await _readSessions();
|
||||
await _readWeights();
|
||||
}
|
||||
|
||||
/// Other apps' weights not read before (D64), each a weight of Train's
|
||||
/// own; one within ten minutes and a tenth of a kilogram of one the
|
||||
/// person entered in Train is the same, and stays out.
|
||||
Future<void> _readWeights() async {
|
||||
final store = weights;
|
||||
if (store == null || !_settings.readsWeights) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final found = await platform.weights(from: _always);
|
||||
final kept = await store.all();
|
||||
final own = [
|
||||
for (final weight in kept)
|
||||
if (weight.content case final content?
|
||||
when content.source == WeightSource.own)
|
||||
content,
|
||||
];
|
||||
final taken = {..._settings.takenWeights};
|
||||
final known = {
|
||||
...taken,
|
||||
for (final weight in kept) ?weight.content?.healthId,
|
||||
};
|
||||
for (final weight in found) {
|
||||
if (known.contains(weight.id)) {
|
||||
continue;
|
||||
}
|
||||
known.add(weight.id);
|
||||
taken.add(weight.id);
|
||||
final double = own.any(
|
||||
(entered) =>
|
||||
entered.at.difference(weight.at).abs() <=
|
||||
const Duration(minutes: 10) &&
|
||||
(entered.kilograms - weight.kilograms).abs() <= 0.1 + 1e-9,
|
||||
);
|
||||
if (!double) {
|
||||
await store.save(
|
||||
WeightContent(
|
||||
at: weight.at,
|
||||
kilograms: weight.kilograms,
|
||||
source: WeightSource.healthConnect,
|
||||
healthId: weight.id,
|
||||
app: weight.app,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_update(_settings.copyWith(takenWeights: taken));
|
||||
} on HealthRefused {
|
||||
_update(_settings.copyWith(readsWeights: false, weightsRefused: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the sessions written since the last read, through the changes
|
||||
/// token; all of them where there is none yet, or it expired.
|
||||
Future<void> _read() => _guarded(() async {
|
||||
Future<void> _readSessions() => _guarded(() async {
|
||||
if (!_settings.reads) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ enum HealthAccess() {
|
|||
|
||||
/// Reading what is older than 30 days before reading was granted.
|
||||
history,
|
||||
|
||||
/// Reading the weights other apps wrote (D64): a scale's, say.
|
||||
weight,
|
||||
}
|
||||
|
||||
/// The kinds of record Train writes, by their names on the channel.
|
||||
|
|
@ -191,6 +194,18 @@ final class const HealthChanges({
|
|||
List<Object?> get props => [token, sessions, expired];
|
||||
}
|
||||
|
||||
/// A weight another app wrote (D64): Health Connect's ID of it, when it was
|
||||
/// measured, how heavy, and the app's package.
|
||||
final class const HealthWeight({
|
||||
required final String id,
|
||||
required final DateTime at,
|
||||
required final double kilograms,
|
||||
required final String app,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, at, kilograms, app];
|
||||
}
|
||||
|
||||
/// The person refused what Health Connect needed, or took it back.
|
||||
final class HealthRefused() implements Exception;
|
||||
|
||||
|
|
@ -233,6 +248,11 @@ abstract interface class HealthPlatform() {
|
|||
/// null where they refused or there is none.
|
||||
Future<List<Fix>?> route(String sessionId);
|
||||
|
||||
/// The weights other apps wrote since [from] (as far back as Health
|
||||
/// Connect lets Train read); throws [HealthRefused] without the
|
||||
/// permission.
|
||||
Future<List<HealthWeight>> weights({required DateTime from});
|
||||
|
||||
/// Opens Health Connect's settings, where what Train is granted changes.
|
||||
Future<void> openSettings();
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue