feat(train): strength plans and sessions (ADR 0035)

Strength plans of exercises from a catalogue or the person's own, in
blocks of one exercise or several done in turn (supersets, circuits),
each with its sets, reps or seconds, weights and rests; each a sealed
`strength_plan` row. Plans are marked with what the place chosen lacks,
and a session offers swaps moving the same way that the place allows.

A session runs on every device, set by set with the rest counted down,
each weight made up from the place's plates, bars and fixed weights with
the plate changes between sets, kept in strength_session.json after each
change so one cut off is carried on, and saved as a workout of the new
activity Strength with its sets, listed on its page; strength training
in Health Connect and FIT, no GPX.

Migration 20261102113104496-strength-plans is purely additive: it
creates the table strength_plan (id uuid v7, spaceId, keyId, the sealed
payload and its nonce), its index on spaceId, and a foreign key to
offline_sync_spaces with ON DELETE CASCADE, so deleting a person's sync
spaces deletes their plans; then the modules' version rows. The
gen_random_uuid_v7 function is created or replaced as in every
migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 13:18:12 +01:00
commit 508994be3a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
48 changed files with 27612 additions and 25 deletions

View file

@ -50,6 +50,7 @@ object HealthRecords {
"hiking" -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING
"cycling" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
"indoorRowing" -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE
"strength" -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING
else -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
}

View file

@ -20,6 +20,7 @@
"activityWalking": "Gehen",
"activityHiking": "Wandern",
"activityCycling": "Radfahren",
"activityStrength": "Krafttraining",
"activityIndoorRowing": "Rudern drinnen",
"activityOther": "Anderes Training",
"recordAutoPause": "Von selbst pausieren",
@ -60,7 +61,7 @@
"recordingNotificationChannel": "Aufzeichnung",
"recordingNotificationPaused": "{activity} · pausiert",
"workoutName": "{when, select, 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}}",
"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.",
"workoutEdit": "Bearbeiten",
@ -634,5 +635,77 @@
"plateOn": "Leg auf: {plates}",
"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}"
"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}}",
"exerciseLoad": "{load, select, barbell{Langhantel mit Scheiben} ezBar{SZ-Stange mit Scheiben} trapBar{Trap Bar mit Scheiben} dumbbellPair{Eine Kurzhantel in jeder Hand} dumbbell{Eine Kurzhantel} kettlebell{Kettlebell} cable{Kabelzug} band{Band} body{Körpergewicht} other{Anderes}}",
"activityStrengthCard": "Kraft",
"strengthPlansHeading": "Kraftpläne",
"strengthPlansNew": "Neuer Kraftplan",
"strengthPlansEmpty": "Kraftpläne listen Übungen mit Sätzen, Wiederholungen, Gewichten und Pausen auf, einzeln oder als Supersätze und Zirkel.",
"strengthPlanLocked": "Lässt sich auf diesem Gerät nicht öffnen",
"strengthPlanDeleted": "Kraftplan gelöscht",
"strengthStart": "Start",
"strengthChoosePlan": "Welcher Plan?",
"strengthNoPlans": "Leg zuerst einen Kraftplan an: Die Liste unten hat „Neuer Kraftplan“.",
"strengthPlanTitle": "Kraftplan",
"strengthPlanName": "Name",
"strengthPlanSave": "Speichern",
"strengthPlanDelete": "Löschen",
"strengthAddExercise": "Übung hinzufügen",
"strengthAddToBlock": "Übung im Wechsel hinzufügen",
"strengthOwnExercise": "Eigene Übung",
"strengthOwnExercises": "Eigene",
"strengthExerciseName": "Was es ist",
"strengthExerciseNeeds": "Was es braucht",
"strengthExerciseLoad": "Womit",
"strengthExercisePattern": "Bewegung",
"strengthExerciseTimed": "Wird gehalten",
"strengthExerciseDone": "Fertig",
"strengthAddSet": "Satz hinzufügen",
"strengthReps": "Wdh.",
"strengthSeconds": "Sekunden",
"strengthKg": "kg",
"strengthRemoveExercise": "Übung entfernen",
"strengthRemoveSet": "Satz entfernen",
"strengthMoveUp": "Nach oben",
"strengthMoveDown": "Nach unten",
"strengthRest": "Pause",
"strengthRestBetween": "Pause zwischen den Übungen",
"strengthDiscardTitle": "Änderungen verwerfen?",
"strengthDiscard": "Verwerfen",
"strengthDiscardMessage": "Was du an diesem Plan geändert hast, geht verloren.",
"strengthAbandon": "Ohne Speichern beenden",
"strengthAbandonMessage": "Die bisherigen Sätze werden von diesem Gerät gelöscht. Dies kann nicht rückgängig gemacht werden.",
"strengthFinish": "Beenden und speichern",
"strengthAllDone": "Alle Sätze sind durch.",
"strengthSetDone": "Geschafft",
"strengthSkip": "Satz überspringen",
"strengthSwap": "Übung tauschen",
"strengthSwapTitle": "Stattdessen",
"strengthNoSwaps": "Nichts anderes hier bewegt sich so.",
"strengthRestSkip": "Pause überspringen",
"strengthResting": "Pause",
"strengthCarryOn": "Ein Training läuft",
"strengthCarryOnAction": "Weiter",
"strengthWeightNotMadeUp": "Hier lässt sich dieses Gewicht nicht zusammenstellen.",
"strengthFixed": "{weight} kg, das nächste darunter",
"strengthNoPlace": "Kein Ort gewählt: Gewichte wie geplant.",
"strengthTrainsAt": "In {place}",
"strengthWorkoutVolume": "{weight} kg bewegt",
"strengthWorkoutSets": "{count, plural, =1{1 Satz} other{{count} Sätze}}",
"strengthMissing": "Fehlt hier: {needs}",
"strengthPlanSummary": "{exercises, plural, =1{1 Übung} other{{exercises} Übungen}} · {sets, plural, =1{1 Satz} other{{sets} Sätze}}",
"strengthBlockKind": "{kind, select, superset{Supersatz} circuit{Zirkel} other{Übung}}",
"strengthSetOf": "Satz {set} von {sets}",
"strengthRoundOf": "Runde {round} von {rounds}",
"strengthTarget": "{reps} × {weight} kg",
"strengthTargetReps": "{reps, plural, =1{1 Wdh.} other{{reps} Wdh.}}",
"strengthTargetSeconds": "{seconds} s",
"strengthNextUp": "Als Nächstes: {exercise}",
"strengthBarAndPlates": "{bar}-kg-Stange, jede Seite: {plates}",
"strengthHandlesAndPlates": "{bar}-kg-Griffe, jede Seite: {plates}",
"strengthWeightShort": "{weight} kg unter dem Plan"
}

View file

@ -22,6 +22,7 @@
"activityWalking": "Walking",
"activityHiking": "Hiking",
"activityCycling": "Cycling",
"activityStrength": "Strength training",
"activityIndoorRowing": "Rowing indoors",
"activityOther": "Other workout",
"recordAutoPause": "Pause by itself",
@ -75,7 +76,7 @@
"recordingNotificationPaused": "{activity} · paused",
"@recordingNotificationPaused": {"description": "The notification's title while paused.", "placeholders": {"activity": {"type": "String", "example": "Running"}}},
"workoutName": "{when, select, morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}",
"workoutName": "{when, select, morning_strength{Morning strength} afternoon_strength{Afternoon strength} evening_strength{Evening strength} night_strength{Night strength} morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}",
"@workoutName": {"description": "A workout's name where the person gave none: the time of day it started (morning 5–12, afternoon 12–17, evening 17–22, night) and its activity, as Strava names them.", "placeholders": {"when": {"type": "String"}}},
"workoutLocked": "Locked workout",
"workoutLockedHint": "This device doesn't hold the key it's sealed with.",
@ -801,5 +802,93 @@
"plateMoves": "{count, plural, =0{No plate moves} =1{1 plate moves on each side} other{{count} plates move on each side}}",
"@plateMoves": {"placeholders": {"count": {"type": "int"}}},
"plateSet": "Set {number}",
"@plateSet": {"placeholders": {"number": {"type": "int"}}}
"@plateSet": {"placeholders": {"number": {"type": "int"}}},
"exerciseName": "{id, select, backSquat{Back squat} frontSquat{Front squat} gobletSquat{Goblet squat} kettlebellGobletSquat{Kettlebell goblet squat} bodyweightSquat{Bodyweight squat} lunge{Lunge} bulgarianSplitSquat{Bulgarian split squat} bodyweightLunge{Bodyweight lunge} deadlift{Deadlift} romanianDeadlift{Romanian deadlift} trapBarDeadlift{Trap bar deadlift} dumbbellRomanianDeadlift{Dumbbell Romanian deadlift} kettlebellSwing{Kettlebell swing} hipThrust{Hip thrust} gluteBridge{Glute bridge} benchPress{Bench press} inclineBenchPress{Incline bench press} dumbbellBenchPress{Dumbbell bench press} pushUp{Push-up} dip{Dip} overheadPress{Overhead press} dumbbellShoulderPress{Dumbbell shoulder press} pikePushUp{Pike push-up} barbellRow{Barbell row} dumbbellRow{One-arm dumbbell row} cableRow{Seated cable row} invertedRow{Inverted row} bandPullApart{Band pull-apart} facePull{Face pull} pullUp{Pull-up} chinUp{Chin-up} latPulldown{Lat pulldown} bandPulldown{Band pulldown} barbellCurl{Barbell curl} ezBarCurl{EZ bar curl} dumbbellCurl{Dumbbell curl} hammerCurl{Hammer curl} tricepsPushdown{Triceps pushdown} skullCrusher{Skull crusher} overheadTricepsExtension{Overhead triceps extension} benchDip{Bench dip} lateralRaise{Lateral raise} cableLateralRaise{Cable lateral raise} calfRaise{Calf raise} plank{Plank} sidePlank{Side plank} hangingLegRaise{Hanging leg raise} crunch{Crunch} farmersCarry{Farmer's carry} other{Exercise}}",
"@exerciseName": {"placeholders": {"id": {"type": "String"}}, "description": "A catalogue exercise's name, by its id (ADR 0035, D1)."},
"exerciseNeed": "{need, select, barbell{a barbell} ezBar{an EZ bar} trapBar{a trap bar} dumbbells{dumbbells} kettlebell{a kettlebell} bench{a bench} rack{a rack} pullUpBar{a pull-up bar} dipBars{dip bars} cableMachine{a cable machine} bands{bands} other{something}}",
"@exerciseNeed": {"placeholders": {"need": {"type": "String"}}, "description": "What an exercise needs, as in \"Missing here: a rack, dip bars\"."},
"exercisePattern": "{pattern, select, squat{Squats} lunge{Lunges} hinge{Hinges} horizontalPush{Pushes} verticalPush{Pushes overhead} horizontalPull{Rows} verticalPull{Pull-ups and pulldowns} curl{Curls} extension{Triceps} raise{Raises} calf{Calves} core{Core} carry{Carries} other{Other}}",
"@exercisePattern": {"placeholders": {"pattern": {"type": "String"}}},
"exerciseLoad": "{load, select, barbell{Barbell with plates} ezBar{EZ bar with plates} trapBar{Trap bar with plates} dumbbellPair{A dumbbell in each hand} dumbbell{One dumbbell} kettlebell{Kettlebell} cable{Cable stack} band{Band} body{Body weight} other{Other}}",
"@exerciseLoad": {"placeholders": {"load": {"type": "String"}}},
"activityStrengthCard": "Strength",
"strengthPlansHeading": "Strength plans",
"strengthPlansNew": "New strength plan",
"strengthPlansEmpty": "Strength plans list exercises with their sets, repetitions, weights and rests, alone or as supersets and circuits.",
"strengthPlanLocked": "Can't be opened on this device",
"strengthPlanDeleted": "Strength plan deleted",
"strengthStart": "Start",
"strengthChoosePlan": "Which plan?",
"strengthNoPlans": "Make a strength plan first: the list below has New strength plan.",
"strengthPlanTitle": "Strength plan",
"strengthPlanName": "Name",
"strengthPlanSave": "Save",
"strengthPlanDelete": "Delete",
"strengthAddExercise": "Add exercise",
"strengthAddToBlock": "Add an exercise to do in turn",
"strengthOwnExercise": "Your own exercise",
"strengthOwnExercises": "Your own",
"strengthExerciseName": "What it is",
"strengthExerciseNeeds": "What it needs",
"strengthExerciseLoad": "What it is done with",
"strengthExercisePattern": "How it moves",
"strengthExerciseTimed": "Held for a time",
"strengthExerciseDone": "Done",
"strengthAddSet": "Add a set",
"strengthReps": "Reps",
"strengthSeconds": "Seconds",
"strengthKg": "kg",
"strengthRemoveExercise": "Remove exercise",
"strengthRemoveSet": "Remove set",
"strengthMoveUp": "Move up",
"strengthMoveDown": "Move down",
"strengthRest": "Rest",
"strengthRestBetween": "Rest between exercises",
"strengthDiscardTitle": "Discard changes?",
"strengthDiscard": "Discard",
"strengthDiscardMessage": "What you changed in this plan is not kept.",
"strengthAbandon": "End without saving",
"strengthAbandonMessage": "The sets done so far are deleted from this device. This can't be undone.",
"strengthFinish": "Finish and save",
"strengthAllDone": "Every set is through.",
"strengthSetDone": "Done",
"strengthSkip": "Skip set",
"strengthSwap": "Swap exercise",
"strengthSwapTitle": "Do instead",
"strengthNoSwaps": "Nothing else here moves this way.",
"strengthRestSkip": "Skip rest",
"strengthResting": "Rest",
"strengthCarryOn": "A session is under way",
"strengthCarryOnAction": "Carry on",
"strengthWeightNotMadeUp": "Nothing here makes this weight up.",
"strengthFixed": "{weight} kg, nearest at or below",
"strengthNoPlace": "No place chosen: weights as planned.",
"strengthTrainsAt": "At {place}",
"strengthWorkoutVolume": "{weight} kg moved",
"strengthWorkoutSets": "{count, plural, =1{1 set} other{{count} sets}}",
"strengthMissing": "Missing here: {needs}",
"@strengthMissing": {"placeholders": {"needs": {"type": "String"}}},
"strengthPlanSummary": "{exercises, plural, =1{1 exercise} other{{exercises} exercises}} · {sets, plural, =1{1 set} other{{sets} sets}}",
"@strengthPlanSummary": {"placeholders": {"exercises": {"type": "int"}, "sets": {"type": "int"}}},
"strengthBlockKind": "{kind, select, superset{Superset} circuit{Circuit} other{Exercise}}",
"@strengthBlockKind": {"placeholders": {"kind": {"type": "String"}}},
"strengthSetOf": "Set {set} of {sets}",
"@strengthSetOf": {"placeholders": {"set": {"type": "int"}, "sets": {"type": "int"}}},
"strengthRoundOf": "Round {round} of {rounds}",
"@strengthRoundOf": {"placeholders": {"round": {"type": "int"}, "rounds": {"type": "int"}}},
"strengthTarget": "{reps} × {weight} kg",
"@strengthTarget": {"placeholders": {"reps": {"type": "int"}, "weight": {"type": "String"}}},
"strengthTargetReps": "{reps, plural, =1{1 rep} other{{reps} reps}}",
"@strengthTargetReps": {"placeholders": {"reps": {"type": "int"}}},
"strengthTargetSeconds": "{seconds} s",
"@strengthTargetSeconds": {"placeholders": {"seconds": {"type": "int"}}},
"strengthNextUp": "Next: {exercise}",
"@strengthNextUp": {"placeholders": {"exercise": {"type": "String"}}},
"strengthBarAndPlates": "{bar} kg bar, each side: {plates}",
"@strengthBarAndPlates": {"placeholders": {"bar": {"type": "String"}, "plates": {"type": "String"}}},
"strengthHandlesAndPlates": "{bar} kg handles, each side: {plates}",
"@strengthHandlesAndPlates": {"placeholders": {"bar": {"type": "String"}, "plates": {"type": "String"}}},
"strengthWeightShort": "{weight} kg below what was planned",
"@strengthWeightShort": {"placeholders": {"weight": {"type": "String"}}}
}

View file

@ -184,6 +184,12 @@ abstract class AppLocalizations {
/// **'Cycling'**
String get activityCycling;
/// No description provided for @activityStrength.
///
/// In en, this message translates to:
/// **'Strength training'**
String get activityStrength;
/// No description provided for @activityIndoorRowing.
///
/// In en, this message translates to:
@ -421,7 +427,7 @@ abstract class AppLocalizations {
/// A workout's name where the person gave none: the time of day it started (morning 5–12, afternoon 12–17, evening 17–22, night) and its activity, as Strava names them.
///
/// In en, this message translates to:
/// **'{when, select, morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}'**
/// **'{when, select, morning_strength{Morning strength} afternoon_strength{Afternoon strength} evening_strength{Evening strength} night_strength{Night strength} morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}'**
String workoutName(String when);
/// No description provided for @workoutLocked.
@ -3825,6 +3831,432 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Set {number}'**
String plateSet(int number);
/// A catalogue exercise's name, by its id (ADR 0035, D1).
///
/// In en, this message translates to:
/// **'{id, select, backSquat{Back squat} frontSquat{Front squat} gobletSquat{Goblet squat} kettlebellGobletSquat{Kettlebell goblet squat} bodyweightSquat{Bodyweight squat} lunge{Lunge} bulgarianSplitSquat{Bulgarian split squat} bodyweightLunge{Bodyweight lunge} deadlift{Deadlift} romanianDeadlift{Romanian deadlift} trapBarDeadlift{Trap bar deadlift} dumbbellRomanianDeadlift{Dumbbell Romanian deadlift} kettlebellSwing{Kettlebell swing} hipThrust{Hip thrust} gluteBridge{Glute bridge} benchPress{Bench press} inclineBenchPress{Incline bench press} dumbbellBenchPress{Dumbbell bench press} pushUp{Push-up} dip{Dip} overheadPress{Overhead press} dumbbellShoulderPress{Dumbbell shoulder press} pikePushUp{Pike push-up} barbellRow{Barbell row} dumbbellRow{One-arm dumbbell row} cableRow{Seated cable row} invertedRow{Inverted row} bandPullApart{Band pull-apart} facePull{Face pull} pullUp{Pull-up} chinUp{Chin-up} latPulldown{Lat pulldown} bandPulldown{Band pulldown} barbellCurl{Barbell curl} ezBarCurl{EZ bar curl} dumbbellCurl{Dumbbell curl} hammerCurl{Hammer curl} tricepsPushdown{Triceps pushdown} skullCrusher{Skull crusher} overheadTricepsExtension{Overhead triceps extension} benchDip{Bench dip} lateralRaise{Lateral raise} cableLateralRaise{Cable lateral raise} calfRaise{Calf raise} plank{Plank} sidePlank{Side plank} hangingLegRaise{Hanging leg raise} crunch{Crunch} farmersCarry{Farmer\'s carry} other{Exercise}}'**
String exerciseName(String id);
/// What an exercise needs, as in "Missing here: a rack, dip bars".
///
/// In en, this message translates to:
/// **'{need, select, barbell{a barbell} ezBar{an EZ bar} trapBar{a trap bar} dumbbells{dumbbells} kettlebell{a kettlebell} bench{a bench} rack{a rack} pullUpBar{a pull-up bar} dipBars{dip bars} cableMachine{a cable machine} bands{bands} other{something}}'**
String exerciseNeed(String need);
/// No description provided for @exercisePattern.
///
/// In en, this message translates to:
/// **'{pattern, select, squat{Squats} lunge{Lunges} hinge{Hinges} horizontalPush{Pushes} verticalPush{Pushes overhead} horizontalPull{Rows} verticalPull{Pull-ups and pulldowns} curl{Curls} extension{Triceps} raise{Raises} calf{Calves} core{Core} carry{Carries} other{Other}}'**
String exercisePattern(String pattern);
/// No description provided for @exerciseLoad.
///
/// In en, this message translates to:
/// **'{load, select, barbell{Barbell with plates} ezBar{EZ bar with plates} trapBar{Trap bar with plates} dumbbellPair{A dumbbell in each hand} dumbbell{One dumbbell} kettlebell{Kettlebell} cable{Cable stack} band{Band} body{Body weight} other{Other}}'**
String exerciseLoad(String load);
/// No description provided for @activityStrengthCard.
///
/// In en, this message translates to:
/// **'Strength'**
String get activityStrengthCard;
/// No description provided for @strengthPlansHeading.
///
/// In en, this message translates to:
/// **'Strength plans'**
String get strengthPlansHeading;
/// No description provided for @strengthPlansNew.
///
/// In en, this message translates to:
/// **'New strength plan'**
String get strengthPlansNew;
/// No description provided for @strengthPlansEmpty.
///
/// In en, this message translates to:
/// **'Strength plans list exercises with their sets, repetitions, weights and rests, alone or as supersets and circuits.'**
String get strengthPlansEmpty;
/// No description provided for @strengthPlanLocked.
///
/// In en, this message translates to:
/// **'Can\'t be opened on this device'**
String get strengthPlanLocked;
/// No description provided for @strengthPlanDeleted.
///
/// In en, this message translates to:
/// **'Strength plan deleted'**
String get strengthPlanDeleted;
/// No description provided for @strengthStart.
///
/// In en, this message translates to:
/// **'Start'**
String get strengthStart;
/// No description provided for @strengthChoosePlan.
///
/// In en, this message translates to:
/// **'Which plan?'**
String get strengthChoosePlan;
/// No description provided for @strengthNoPlans.
///
/// In en, this message translates to:
/// **'Make a strength plan first: the list below has New strength plan.'**
String get strengthNoPlans;
/// No description provided for @strengthPlanTitle.
///
/// In en, this message translates to:
/// **'Strength plan'**
String get strengthPlanTitle;
/// No description provided for @strengthPlanName.
///
/// In en, this message translates to:
/// **'Name'**
String get strengthPlanName;
/// No description provided for @strengthPlanSave.
///
/// In en, this message translates to:
/// **'Save'**
String get strengthPlanSave;
/// No description provided for @strengthPlanDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get strengthPlanDelete;
/// No description provided for @strengthAddExercise.
///
/// In en, this message translates to:
/// **'Add exercise'**
String get strengthAddExercise;
/// No description provided for @strengthAddToBlock.
///
/// In en, this message translates to:
/// **'Add an exercise to do in turn'**
String get strengthAddToBlock;
/// No description provided for @strengthOwnExercise.
///
/// In en, this message translates to:
/// **'Your own exercise'**
String get strengthOwnExercise;
/// No description provided for @strengthOwnExercises.
///
/// In en, this message translates to:
/// **'Your own'**
String get strengthOwnExercises;
/// No description provided for @strengthExerciseName.
///
/// In en, this message translates to:
/// **'What it is'**
String get strengthExerciseName;
/// No description provided for @strengthExerciseNeeds.
///
/// In en, this message translates to:
/// **'What it needs'**
String get strengthExerciseNeeds;
/// No description provided for @strengthExerciseLoad.
///
/// In en, this message translates to:
/// **'What it is done with'**
String get strengthExerciseLoad;
/// No description provided for @strengthExercisePattern.
///
/// In en, this message translates to:
/// **'How it moves'**
String get strengthExercisePattern;
/// No description provided for @strengthExerciseTimed.
///
/// In en, this message translates to:
/// **'Held for a time'**
String get strengthExerciseTimed;
/// No description provided for @strengthExerciseDone.
///
/// In en, this message translates to:
/// **'Done'**
String get strengthExerciseDone;
/// No description provided for @strengthAddSet.
///
/// In en, this message translates to:
/// **'Add a set'**
String get strengthAddSet;
/// No description provided for @strengthReps.
///
/// In en, this message translates to:
/// **'Reps'**
String get strengthReps;
/// No description provided for @strengthSeconds.
///
/// In en, this message translates to:
/// **'Seconds'**
String get strengthSeconds;
/// No description provided for @strengthKg.
///
/// In en, this message translates to:
/// **'kg'**
String get strengthKg;
/// No description provided for @strengthRemoveExercise.
///
/// In en, this message translates to:
/// **'Remove exercise'**
String get strengthRemoveExercise;
/// No description provided for @strengthRemoveSet.
///
/// In en, this message translates to:
/// **'Remove set'**
String get strengthRemoveSet;
/// No description provided for @strengthMoveUp.
///
/// In en, this message translates to:
/// **'Move up'**
String get strengthMoveUp;
/// No description provided for @strengthMoveDown.
///
/// In en, this message translates to:
/// **'Move down'**
String get strengthMoveDown;
/// No description provided for @strengthRest.
///
/// In en, this message translates to:
/// **'Rest'**
String get strengthRest;
/// No description provided for @strengthRestBetween.
///
/// In en, this message translates to:
/// **'Rest between exercises'**
String get strengthRestBetween;
/// No description provided for @strengthDiscardTitle.
///
/// In en, this message translates to:
/// **'Discard changes?'**
String get strengthDiscardTitle;
/// No description provided for @strengthDiscard.
///
/// In en, this message translates to:
/// **'Discard'**
String get strengthDiscard;
/// No description provided for @strengthDiscardMessage.
///
/// In en, this message translates to:
/// **'What you changed in this plan is not kept.'**
String get strengthDiscardMessage;
/// No description provided for @strengthAbandon.
///
/// In en, this message translates to:
/// **'End without saving'**
String get strengthAbandon;
/// No description provided for @strengthAbandonMessage.
///
/// In en, this message translates to:
/// **'The sets done so far are deleted from this device. This can\'t be undone.'**
String get strengthAbandonMessage;
/// No description provided for @strengthFinish.
///
/// In en, this message translates to:
/// **'Finish and save'**
String get strengthFinish;
/// No description provided for @strengthAllDone.
///
/// In en, this message translates to:
/// **'Every set is through.'**
String get strengthAllDone;
/// No description provided for @strengthSetDone.
///
/// In en, this message translates to:
/// **'Done'**
String get strengthSetDone;
/// No description provided for @strengthSkip.
///
/// In en, this message translates to:
/// **'Skip set'**
String get strengthSkip;
/// No description provided for @strengthSwap.
///
/// In en, this message translates to:
/// **'Swap exercise'**
String get strengthSwap;
/// No description provided for @strengthSwapTitle.
///
/// In en, this message translates to:
/// **'Do instead'**
String get strengthSwapTitle;
/// No description provided for @strengthNoSwaps.
///
/// In en, this message translates to:
/// **'Nothing else here moves this way.'**
String get strengthNoSwaps;
/// No description provided for @strengthRestSkip.
///
/// In en, this message translates to:
/// **'Skip rest'**
String get strengthRestSkip;
/// No description provided for @strengthResting.
///
/// In en, this message translates to:
/// **'Rest'**
String get strengthResting;
/// No description provided for @strengthCarryOn.
///
/// In en, this message translates to:
/// **'A session is under way'**
String get strengthCarryOn;
/// No description provided for @strengthCarryOnAction.
///
/// In en, this message translates to:
/// **'Carry on'**
String get strengthCarryOnAction;
/// No description provided for @strengthWeightNotMadeUp.
///
/// In en, this message translates to:
/// **'Nothing here makes this weight up.'**
String get strengthWeightNotMadeUp;
/// No description provided for @strengthFixed.
///
/// In en, this message translates to:
/// **'{weight} kg, nearest at or below'**
String strengthFixed(Object weight);
/// No description provided for @strengthNoPlace.
///
/// In en, this message translates to:
/// **'No place chosen: weights as planned.'**
String get strengthNoPlace;
/// No description provided for @strengthTrainsAt.
///
/// In en, this message translates to:
/// **'At {place}'**
String strengthTrainsAt(Object place);
/// No description provided for @strengthWorkoutVolume.
///
/// In en, this message translates to:
/// **'{weight} kg moved'**
String strengthWorkoutVolume(Object weight);
/// No description provided for @strengthWorkoutSets.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 set} other{{count} sets}}'**
String strengthWorkoutSets(num count);
/// No description provided for @strengthMissing.
///
/// In en, this message translates to:
/// **'Missing here: {needs}'**
String strengthMissing(String needs);
/// No description provided for @strengthPlanSummary.
///
/// In en, this message translates to:
/// **'{exercises, plural, =1{1 exercise} other{{exercises} exercises}} · {sets, plural, =1{1 set} other{{sets} sets}}'**
String strengthPlanSummary(int exercises, int sets);
/// No description provided for @strengthBlockKind.
///
/// In en, this message translates to:
/// **'{kind, select, superset{Superset} circuit{Circuit} other{Exercise}}'**
String strengthBlockKind(String kind);
/// No description provided for @strengthSetOf.
///
/// In en, this message translates to:
/// **'Set {set} of {sets}'**
String strengthSetOf(int set, int sets);
/// No description provided for @strengthRoundOf.
///
/// In en, this message translates to:
/// **'Round {round} of {rounds}'**
String strengthRoundOf(int round, int rounds);
/// No description provided for @strengthTarget.
///
/// In en, this message translates to:
/// **'{reps} × {weight} kg'**
String strengthTarget(int reps, String weight);
/// No description provided for @strengthTargetReps.
///
/// In en, this message translates to:
/// **'{reps, plural, =1{1 rep} other{{reps} reps}}'**
String strengthTargetReps(int reps);
/// No description provided for @strengthTargetSeconds.
///
/// In en, this message translates to:
/// **'{seconds} s'**
String strengthTargetSeconds(int seconds);
/// No description provided for @strengthNextUp.
///
/// In en, this message translates to:
/// **'Next: {exercise}'**
String strengthNextUp(String exercise);
/// No description provided for @strengthBarAndPlates.
///
/// In en, this message translates to:
/// **'{bar} kg bar, each side: {plates}'**
String strengthBarAndPlates(String bar, String plates);
/// No description provided for @strengthHandlesAndPlates.
///
/// In en, this message translates to:
/// **'{bar} kg handles, each side: {plates}'**
String strengthHandlesAndPlates(String bar, String plates);
/// No description provided for @strengthWeightShort.
///
/// In en, this message translates to:
/// **'{weight} kg below what was planned'**
String strengthWeightShort(String weight);
}
class _AppLocalizationsDelegate

View file

@ -54,6 +54,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get activityCycling => 'Radfahren';
@override
String get activityStrength => 'Krafttraining';
@override
String get activityIndoorRowing => 'Rudern drinnen';
@ -199,6 +202,10 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.selectLogic(
when,
{
'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',
@ -2332,4 +2339,400 @@ class AppLocalizationsDe extends AppLocalizations {
String plateSet(int number) {
return 'Satz $number';
}
@override
String exerciseName(String id) {
String _temp0 = intl.Intl.selectLogic(
id,
{
'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',
},
);
return '$_temp0';
}
@override
String exerciseNeed(String need) {
String _temp0 = intl.Intl.selectLogic(
need,
{
'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',
},
);
return '$_temp0';
}
@override
String exercisePattern(String pattern) {
String _temp0 = intl.Intl.selectLogic(
pattern,
{
'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',
},
);
return '$_temp0';
}
@override
String exerciseLoad(String load) {
String _temp0 = intl.Intl.selectLogic(
load,
{
'barbell': 'Langhantel mit Scheiben',
'ezBar': 'SZ-Stange mit Scheiben',
'trapBar': 'Trap Bar mit Scheiben',
'dumbbellPair': 'Eine Kurzhantel in jeder Hand',
'dumbbell': 'Eine Kurzhantel',
'kettlebell': 'Kettlebell',
'cable': 'Kabelzug',
'band': 'Band',
'body': 'Körpergewicht',
'other': 'Anderes',
},
);
return '$_temp0';
}
@override
String get activityStrengthCard => 'Kraft';
@override
String get strengthPlansHeading => 'Kraftpläne';
@override
String get strengthPlansNew => 'Neuer Kraftplan';
@override
String get strengthPlansEmpty =>
'Kraftpläne listen Übungen mit Sätzen, Wiederholungen, Gewichten und Pausen auf, einzeln oder als Supersätze und Zirkel.';
@override
String get strengthPlanLocked => 'Lässt sich auf diesem Gerät nicht öffnen';
@override
String get strengthPlanDeleted => 'Kraftplan gelöscht';
@override
String get strengthStart => 'Start';
@override
String get strengthChoosePlan => 'Welcher Plan?';
@override
String get strengthNoPlans =>
'Leg zuerst einen Kraftplan an: Die Liste unten hat „Neuer Kraftplan“.';
@override
String get strengthPlanTitle => 'Kraftplan';
@override
String get strengthPlanName => 'Name';
@override
String get strengthPlanSave => 'Speichern';
@override
String get strengthPlanDelete => 'Löschen';
@override
String get strengthAddExercise => 'Übung hinzufügen';
@override
String get strengthAddToBlock => 'Übung im Wechsel hinzufügen';
@override
String get strengthOwnExercise => 'Eigene Übung';
@override
String get strengthOwnExercises => 'Eigene';
@override
String get strengthExerciseName => 'Was es ist';
@override
String get strengthExerciseNeeds => 'Was es braucht';
@override
String get strengthExerciseLoad => 'Womit';
@override
String get strengthExercisePattern => 'Bewegung';
@override
String get strengthExerciseTimed => 'Wird gehalten';
@override
String get strengthExerciseDone => 'Fertig';
@override
String get strengthAddSet => 'Satz hinzufügen';
@override
String get strengthReps => 'Wdh.';
@override
String get strengthSeconds => 'Sekunden';
@override
String get strengthKg => 'kg';
@override
String get strengthRemoveExercise => 'Übung entfernen';
@override
String get strengthRemoveSet => 'Satz entfernen';
@override
String get strengthMoveUp => 'Nach oben';
@override
String get strengthMoveDown => 'Nach unten';
@override
String get strengthRest => 'Pause';
@override
String get strengthRestBetween => 'Pause zwischen den Übungen';
@override
String get strengthDiscardTitle => 'Änderungen verwerfen?';
@override
String get strengthDiscard => 'Verwerfen';
@override
String get strengthDiscardMessage =>
'Was du an diesem Plan geändert hast, geht verloren.';
@override
String get strengthAbandon => 'Ohne Speichern beenden';
@override
String get strengthAbandonMessage =>
'Die bisherigen Sätze werden von diesem Gerät gelöscht. Dies kann nicht rückgängig gemacht werden.';
@override
String get strengthFinish => 'Beenden und speichern';
@override
String get strengthAllDone => 'Alle Sätze sind durch.';
@override
String get strengthSetDone => 'Geschafft';
@override
String get strengthSkip => 'Satz überspringen';
@override
String get strengthSwap => 'Übung tauschen';
@override
String get strengthSwapTitle => 'Stattdessen';
@override
String get strengthNoSwaps => 'Nichts anderes hier bewegt sich so.';
@override
String get strengthRestSkip => 'Pause überspringen';
@override
String get strengthResting => 'Pause';
@override
String get strengthCarryOn => 'Ein Training läuft';
@override
String get strengthCarryOnAction => 'Weiter';
@override
String get strengthWeightNotMadeUp =>
'Hier lässt sich dieses Gewicht nicht zusammenstellen.';
@override
String strengthFixed(Object weight) {
return '$weight kg, das nächste darunter';
}
@override
String get strengthNoPlace => 'Kein Ort gewählt: Gewichte wie geplant.';
@override
String strengthTrainsAt(Object place) {
return 'In $place';
}
@override
String strengthWorkoutVolume(Object weight) {
return '$weight kg bewegt';
}
@override
String strengthWorkoutSets(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Sätze',
one: '1 Satz',
);
return '$_temp0';
}
@override
String strengthMissing(String needs) {
return 'Fehlt hier: $needs';
}
@override
String strengthPlanSummary(int exercises, int sets) {
String _temp0 = intl.Intl.pluralLogic(
exercises,
locale: localeName,
other: '$exercises Übungen',
one: '1 Übung',
);
String _temp1 = intl.Intl.pluralLogic(
sets,
locale: localeName,
other: '$sets Sätze',
one: '1 Satz',
);
return '$_temp0 · $_temp1';
}
@override
String strengthBlockKind(String kind) {
String _temp0 = intl.Intl.selectLogic(
kind,
{
'superset': 'Supersatz',
'circuit': 'Zirkel',
'other': 'Übung',
},
);
return '$_temp0';
}
@override
String strengthSetOf(int set, int sets) {
return 'Satz $set von $sets';
}
@override
String strengthRoundOf(int round, int rounds) {
return 'Runde $round von $rounds';
}
@override
String strengthTarget(int reps, String weight) {
return '$reps × $weight kg';
}
@override
String strengthTargetReps(int reps) {
String _temp0 = intl.Intl.pluralLogic(
reps,
locale: localeName,
other: '$reps Wdh.',
one: '1 Wdh.',
);
return '$_temp0';
}
@override
String strengthTargetSeconds(int seconds) {
return '$seconds s';
}
@override
String strengthNextUp(String exercise) {
return 'Als Nächstes: $exercise';
}
@override
String strengthBarAndPlates(String bar, String plates) {
return '$bar-kg-Stange, jede Seite: $plates';
}
@override
String strengthHandlesAndPlates(String bar, String plates) {
return '$bar-kg-Griffe, jede Seite: $plates';
}
@override
String strengthWeightShort(String weight) {
return '$weight kg unter dem Plan';
}
}

View file

@ -53,6 +53,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get activityCycling => 'Cycling';
@override
String get activityStrength => 'Strength training';
@override
String get activityIndoorRowing => 'Rowing indoors';
@ -197,6 +200,10 @@ class AppLocalizationsEn extends AppLocalizations {
String _temp0 = intl.Intl.selectLogic(
when,
{
'morning_strength': 'Morning strength',
'afternoon_strength': 'Afternoon strength',
'evening_strength': 'Evening strength',
'night_strength': 'Night strength',
'morning_running': 'Morning run',
'morning_walking': 'Morning walk',
'morning_hiking': 'Morning hike',
@ -2320,6 +2327,401 @@ class AppLocalizationsEn extends AppLocalizations {
String plateSet(int number) {
return 'Set $number';
}
@override
String exerciseName(String id) {
String _temp0 = intl.Intl.selectLogic(
id,
{
'backSquat': 'Back squat',
'frontSquat': 'Front squat',
'gobletSquat': 'Goblet squat',
'kettlebellGobletSquat': 'Kettlebell goblet squat',
'bodyweightSquat': 'Bodyweight squat',
'lunge': 'Lunge',
'bulgarianSplitSquat': 'Bulgarian split squat',
'bodyweightLunge': 'Bodyweight lunge',
'deadlift': 'Deadlift',
'romanianDeadlift': 'Romanian deadlift',
'trapBarDeadlift': 'Trap bar deadlift',
'dumbbellRomanianDeadlift': 'Dumbbell Romanian deadlift',
'kettlebellSwing': 'Kettlebell swing',
'hipThrust': 'Hip thrust',
'gluteBridge': 'Glute bridge',
'benchPress': 'Bench press',
'inclineBenchPress': 'Incline bench press',
'dumbbellBenchPress': 'Dumbbell bench press',
'pushUp': 'Push-up',
'dip': 'Dip',
'overheadPress': 'Overhead press',
'dumbbellShoulderPress': 'Dumbbell shoulder press',
'pikePushUp': 'Pike push-up',
'barbellRow': 'Barbell row',
'dumbbellRow': 'One-arm dumbbell row',
'cableRow': 'Seated cable row',
'invertedRow': 'Inverted row',
'bandPullApart': 'Band pull-apart',
'facePull': 'Face pull',
'pullUp': 'Pull-up',
'chinUp': 'Chin-up',
'latPulldown': 'Lat pulldown',
'bandPulldown': 'Band pulldown',
'barbellCurl': 'Barbell curl',
'ezBarCurl': 'EZ bar curl',
'dumbbellCurl': 'Dumbbell curl',
'hammerCurl': 'Hammer curl',
'tricepsPushdown': 'Triceps pushdown',
'skullCrusher': 'Skull crusher',
'overheadTricepsExtension': 'Overhead triceps extension',
'benchDip': 'Bench dip',
'lateralRaise': 'Lateral raise',
'cableLateralRaise': 'Cable lateral raise',
'calfRaise': 'Calf raise',
'plank': 'Plank',
'sidePlank': 'Side plank',
'hangingLegRaise': 'Hanging leg raise',
'crunch': 'Crunch',
'farmersCarry': 'Farmer\'s carry',
'other': 'Exercise',
},
);
return '$_temp0';
}
@override
String exerciseNeed(String need) {
String _temp0 = intl.Intl.selectLogic(
need,
{
'barbell': 'a barbell',
'ezBar': 'an EZ bar',
'trapBar': 'a trap bar',
'dumbbells': 'dumbbells',
'kettlebell': 'a kettlebell',
'bench': 'a bench',
'rack': 'a rack',
'pullUpBar': 'a pull-up bar',
'dipBars': 'dip bars',
'cableMachine': 'a cable machine',
'bands': 'bands',
'other': 'something',
},
);
return '$_temp0';
}
@override
String exercisePattern(String pattern) {
String _temp0 = intl.Intl.selectLogic(
pattern,
{
'squat': 'Squats',
'lunge': 'Lunges',
'hinge': 'Hinges',
'horizontalPush': 'Pushes',
'verticalPush': 'Pushes overhead',
'horizontalPull': 'Rows',
'verticalPull': 'Pull-ups and pulldowns',
'curl': 'Curls',
'extension': 'Triceps',
'raise': 'Raises',
'calf': 'Calves',
'core': 'Core',
'carry': 'Carries',
'other': 'Other',
},
);
return '$_temp0';
}
@override
String exerciseLoad(String load) {
String _temp0 = intl.Intl.selectLogic(
load,
{
'barbell': 'Barbell with plates',
'ezBar': 'EZ bar with plates',
'trapBar': 'Trap bar with plates',
'dumbbellPair': 'A dumbbell in each hand',
'dumbbell': 'One dumbbell',
'kettlebell': 'Kettlebell',
'cable': 'Cable stack',
'band': 'Band',
'body': 'Body weight',
'other': 'Other',
},
);
return '$_temp0';
}
@override
String get activityStrengthCard => 'Strength';
@override
String get strengthPlansHeading => 'Strength plans';
@override
String get strengthPlansNew => 'New strength plan';
@override
String get strengthPlansEmpty =>
'Strength plans list exercises with their sets, repetitions, weights and rests, alone or as supersets and circuits.';
@override
String get strengthPlanLocked => 'Can\'t be opened on this device';
@override
String get strengthPlanDeleted => 'Strength plan deleted';
@override
String get strengthStart => 'Start';
@override
String get strengthChoosePlan => 'Which plan?';
@override
String get strengthNoPlans =>
'Make a strength plan first: the list below has New strength plan.';
@override
String get strengthPlanTitle => 'Strength plan';
@override
String get strengthPlanName => 'Name';
@override
String get strengthPlanSave => 'Save';
@override
String get strengthPlanDelete => 'Delete';
@override
String get strengthAddExercise => 'Add exercise';
@override
String get strengthAddToBlock => 'Add an exercise to do in turn';
@override
String get strengthOwnExercise => 'Your own exercise';
@override
String get strengthOwnExercises => 'Your own';
@override
String get strengthExerciseName => 'What it is';
@override
String get strengthExerciseNeeds => 'What it needs';
@override
String get strengthExerciseLoad => 'What it is done with';
@override
String get strengthExercisePattern => 'How it moves';
@override
String get strengthExerciseTimed => 'Held for a time';
@override
String get strengthExerciseDone => 'Done';
@override
String get strengthAddSet => 'Add a set';
@override
String get strengthReps => 'Reps';
@override
String get strengthSeconds => 'Seconds';
@override
String get strengthKg => 'kg';
@override
String get strengthRemoveExercise => 'Remove exercise';
@override
String get strengthRemoveSet => 'Remove set';
@override
String get strengthMoveUp => 'Move up';
@override
String get strengthMoveDown => 'Move down';
@override
String get strengthRest => 'Rest';
@override
String get strengthRestBetween => 'Rest between exercises';
@override
String get strengthDiscardTitle => 'Discard changes?';
@override
String get strengthDiscard => 'Discard';
@override
String get strengthDiscardMessage =>
'What you changed in this plan is not kept.';
@override
String get strengthAbandon => 'End without saving';
@override
String get strengthAbandonMessage =>
'The sets done so far are deleted from this device. This can\'t be undone.';
@override
String get strengthFinish => 'Finish and save';
@override
String get strengthAllDone => 'Every set is through.';
@override
String get strengthSetDone => 'Done';
@override
String get strengthSkip => 'Skip set';
@override
String get strengthSwap => 'Swap exercise';
@override
String get strengthSwapTitle => 'Do instead';
@override
String get strengthNoSwaps => 'Nothing else here moves this way.';
@override
String get strengthRestSkip => 'Skip rest';
@override
String get strengthResting => 'Rest';
@override
String get strengthCarryOn => 'A session is under way';
@override
String get strengthCarryOnAction => 'Carry on';
@override
String get strengthWeightNotMadeUp => 'Nothing here makes this weight up.';
@override
String strengthFixed(Object weight) {
return '$weight kg, nearest at or below';
}
@override
String get strengthNoPlace => 'No place chosen: weights as planned.';
@override
String strengthTrainsAt(Object place) {
return 'At $place';
}
@override
String strengthWorkoutVolume(Object weight) {
return '$weight kg moved';
}
@override
String strengthWorkoutSets(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count sets',
one: '1 set',
);
return '$_temp0';
}
@override
String strengthMissing(String needs) {
return 'Missing here: $needs';
}
@override
String strengthPlanSummary(int exercises, int sets) {
String _temp0 = intl.Intl.pluralLogic(
exercises,
locale: localeName,
other: '$exercises exercises',
one: '1 exercise',
);
String _temp1 = intl.Intl.pluralLogic(
sets,
locale: localeName,
other: '$sets sets',
one: '1 set',
);
return '$_temp0 · $_temp1';
}
@override
String strengthBlockKind(String kind) {
String _temp0 = intl.Intl.selectLogic(
kind,
{
'superset': 'Superset',
'circuit': 'Circuit',
'other': 'Exercise',
},
);
return '$_temp0';
}
@override
String strengthSetOf(int set, int sets) {
return 'Set $set of $sets';
}
@override
String strengthRoundOf(int round, int rounds) {
return 'Round $round of $rounds';
}
@override
String strengthTarget(int reps, String weight) {
return '$reps × $weight kg';
}
@override
String strengthTargetReps(int reps) {
String _temp0 = intl.Intl.pluralLogic(
reps,
locale: localeName,
other: '$reps reps',
one: '1 rep',
);
return '$_temp0';
}
@override
String strengthTargetSeconds(int seconds) {
return '$seconds s';
}
@override
String strengthNextUp(String exercise) {
return 'Next: $exercise';
}
@override
String strengthBarAndPlates(String bar, String plates) {
return '$bar kg bar, each side: $plates';
}
@override
String strengthHandlesAndPlates(String bar, String plates) {
return '$bar kg handles, each side: $plates';
}
@override
String strengthWeightShort(String weight) {
return '$weight kg below what was planned';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -7,6 +7,7 @@ import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/app/train_router.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/bloc/places_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/recorder_music.dart';
@ -17,6 +18,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/widget/announce_spee
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
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/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -122,6 +124,23 @@ class _TrainPersonAppState() extends State<TrainPersonApp> {
return PlansCubit(train.plans, train.planDeletions)..open();
},
),
// The places and the strength plans, which the plans' lists, a
// session and Equipment share (ADR 0034, ADR 0035).
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);
return PlacesCubit(train.places, train.chosenPlace)..open();
},
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);
return StrengthPlansCubit(
train.strengthPlans,
train.strengthPlanDeletions,
)..open();
},
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);

View file

@ -40,6 +40,9 @@ import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/sync_route_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_repository.dart';
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/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';
@ -92,6 +95,12 @@ class TrainPerson(
/// The place last chosen on this device (ADR 0034, D4).
required final ChosenPlaceStore chosenPlace,
/// Their strength plans (ADR 0035), sealed and synced.
required final StrengthPlanRepository strengthPlans,
/// The strength session under way on this device (ADR 0035, D3).
required final StrengthSessionStore strengthSession,
/// The instance's planning of routes (D44).
required final RoutingGateway routing,
@ -141,6 +150,12 @@ class TrainPerson(
/// Plans deleted, to be undone for a moment, as workouts are.
late final planDeletions = DelayedDeletions(plans.delete, logName: 'Plans');
/// Strength plans deleted, to be undone for a moment, as plans are.
late final strengthPlanDeletions = DelayedDeletions(
strengthPlans.delete,
logName: 'Strength plans',
);
/// Routes deleted, to be undone for a moment, as workouts are.
late final routeDeletions = DelayedDeletions(
routes.delete,
@ -414,6 +429,10 @@ 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 strengthPlans = StrengthPlanRepository(
SyncStrengthPlanGateway(db, sync),
keys,
);
final maps = MapSourceRepository(
ServerpodMapSourceGateway(client),
kept: File(p.join(folder.path, 'map_source.json')),
@ -473,6 +492,10 @@ Future<TrainPerson> _openPersonData(
routes: routes,
places: places,
chosenPlace: ChosenPlaceStore(File(p.join(folder.path, 'place.json'))),
strengthPlans: strengthPlans,
strengthSession: StrengthSessionStore(
File(p.join(folder.path, 'strength_session.json')),
),
routing: ServerpodRoutingGateway(client),
placeSearch: ServerpodPlaceSearchGateway(client),
maps: maps,
@ -510,6 +533,7 @@ Future<TrainPerson> _openPersonData(
await plans.dispose();
await routes.dispose();
await places.dispose();
await strengthPlans.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -1,9 +1,7 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/bloc/places_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/widget/place_screen.dart';
@ -14,14 +12,13 @@ import 'package:material_ui/material_ui.dart';
/// Opens *Equipment* over [context]'s, from Settings.
Future<void> openEquipmentScreen(BuildContext context) {
final train = PersonScope.of<TrainPerson>(context);
// The person's app's, which the plans' lists share, so that a place
// chosen here is chosen there.
final places = context.read<PlacesCubit>();
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider(
create: (context) =>
PlacesCubit(train.places, train.chosenPlace)..open(),
child: const EquipmentScreen(),
),
builder: (context) =>
BlocProvider.value(value: places, child: const EquipmentScreen()),
),
);
}

View file

@ -194,8 +194,9 @@ class RecordingBloc(
Activity.walking ||
Activity.hiking => AutoPauseBy.steps,
Activity.indoorRowing => AutoPauseBy.strokes,
// Not recorded on the phone (D39): only the Loop brings it.
Activity.other => null,
// Not recorded on the phone (D39): only the Loop brings it; a
// strength session is the app's (ADR 0035).
Activity.other || Activity.strength => null,
};
final granted = await _platform.ask({
// The rower's connection, which also gives Android its service's type.

View file

@ -10,7 +10,7 @@ const _distancesSpelled = 100;
int? announceEvery(Activity activity) => switch (activity) {
Activity.running || Activity.walking || Activity.hiking => 1000,
Activity.cycling => 5000,
Activity.indoorRowing || Activity.other => null,
Activity.indoorRowing || Activity.other || Activity.strength => null,
};
/// The announcements' words for [activity] as the recorder takes them, in

View file

@ -13,6 +13,7 @@ String activityName(AppLocalizations l10n, Activity activity) =>
Activity.hiking => l10n.activityHiking,
Activity.cycling => l10n.activityCycling,
Activity.indoorRowing => l10n.activityIndoorRowing,
Activity.strength => l10n.activityStrength,
Activity.other => l10n.activityOther,
};

View file

@ -0,0 +1,192 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
/// A strength plan being made or changed (ADR 0035, D2): exercises added
/// as blocks of their own or to a block, to be done in turn; their sets
/// added, changed and removed; the rests set; saved once.
class StrengthEditorCubit(
final StrengthPlanRepository _plans, {
required StrengthPlanContent plan,
final String? id,
}) extends Cubit<StrengthEditorState> {
this : super(StrengthEditorState(plan: plan, id: id));
/// A new exercise's sets: three of ten, or held for 30 s.
static List<StrengthSet> setsFor(Exercise exercise) => List.filled(
3,
exercise.timed
? const StrengthSet(seconds: 30)
: const StrengthSet(reps: 10),
);
void rename(String name) => _change(state.plan.copyWith(name: name));
/// [exercise] as a block of its own, at the end.
void addExercise(Exercise exercise) => _blocks([
...state.plan.blocks,
StrengthBlock(
exercises: [
PlannedExercise(exercise: exercise, sets: setsFor(exercise)),
],
),
]);
/// [exercise] added to the block [block], to be done in turn with what
/// it holds, with as many sets as its first.
void addToBlock(int block, Exercise exercise) => _block(block, (current) {
final like = current.exercises.first.sets.length;
final sets = setsFor(exercise);
return current.copyWith(
exercises: [
...current.exercises,
PlannedExercise(
exercise: exercise,
sets: List.filled(like, sets.first),
),
],
);
});
/// The exercise [exercise] of [block] taken out, the block with it where
/// it held no other.
void removeExercise(int block, int exercise) {
final current = state.plan.blocks[block];
if (current.exercises.length == 1) {
_blocks([...state.plan.blocks]..removeAt(block));
} else {
_block(
block,
(current) => current.copyWith(
exercises: [...current.exercises]..removeAt(exercise),
),
);
}
}
void moveBlock(int block, {required bool up}) {
final to = up ? block - 1 : block + 1;
if (to < 0 || to >= state.plan.blocks.length) {
return;
}
final blocks = [...state.plan.blocks];
final moved = blocks.removeAt(block);
_blocks(blocks..insert(to, moved));
}
void setSet(int block, int exercise, int set, StrengthSet value) => _exercise(
block,
exercise,
(current) => current.copyWith(
sets: [...current.sets]..[set] = value,
),
);
/// A set added like the last.
void addSet(int block, int exercise) => _exercise(
block,
exercise,
(current) => current.copyWith(
sets: [
...current.sets,
current.sets.lastOrNull ?? setsFor(current.exercise).first,
],
),
);
void removeSet(int block, int exercise, int set) => _exercise(
block,
exercise,
(current) => current.copyWith(sets: [...current.sets]..removeAt(set)),
);
void setRest(int block, Duration rest) =>
_block(block, (current) => current.copyWith(rest: rest));
void setRestBetween(int block, Duration rest) =>
_block(block, (current) => current.copyWith(restBetween: rest));
/// Keeps the plan: a new one made, a kept one changed.
Future<void> save() async {
final id = state.id;
if (!state.savable) {
return;
}
emit(state._with(saving: true));
final plan = state.plan.copyWith(name: state.plan.name.trim());
try {
if (id == null) {
final made = await _plans.save(plan);
emit(state._with(id: made, saved: true));
} else {
await _plans.update(id, plan);
emit(state._with(saved: true));
}
} on Object {
// Not kept (this device can't seal, say): as before, for the screen
// to say so.
emit(state._with(saving: false));
rethrow;
}
}
void _exercise(
int block,
int exercise,
PlannedExercise Function(PlannedExercise current) change,
) => _block(
block,
(current) => current.copyWith(
exercises: [...current.exercises]
..[exercise] = change(current.exercises[exercise]),
),
);
void _block(
int block,
StrengthBlock Function(StrengthBlock current) change,
) => _blocks(
[...state.plan.blocks]..[block] = change(state.plan.blocks[block]),
);
void _blocks(List<StrengthBlock> blocks) =>
_change(state.plan.copyWith(blocks: blocks));
void _change(StrengthPlanContent plan) =>
emit(state._with(plan: plan, changed: true));
}
final class const StrengthEditorState({
required final StrengthPlanContent plan,
final String? id,
final bool changed = false,
final bool saving = false,
final bool saved = false,
}) with Equatable {
/// Whether it can be kept: named, with an exercise of a set.
bool get savable =>
!saving &&
plan.name.trim().isNotEmpty &&
plan.blocks.any(
(block) => block.exercises.any((exercise) => exercise.sets.isNotEmpty),
);
StrengthEditorState _with({
StrengthPlanContent? plan,
String? id,
bool? changed,
bool? saving,
bool? saved,
}) => StrengthEditorState(
plan: plan ?? this.plan,
id: id ?? this.id,
changed: changed ?? this.changed,
saving: saving ?? (saved != true && this.saving),
saved: saved ?? this.saved,
);
@override
List<Object?> get props => [plan, id, changed, saving, saved];
}

View file

@ -0,0 +1,90 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
/// The person's strength plans as the lists show them (ADR 0035): by name, those
/// this device can't open last; read again whenever they change, here or on
/// another device. A deletion leaves the list at once and can be undone for
/// a moment ([DelayedDeletions]).
class StrengthPlansCubit(
final StrengthPlanRepository _plans,
final DelayedDeletions _deletions,
) extends Cubit<StrengthPlansState> {
this : super(const StrengthPlansState());
StreamSubscription<void>? _changes;
StreamSubscription<Set<String>>? _pending;
Future<void> open() async {
_changes = _plans.changes.listen((_) => unawaited(_read()));
_pending = _deletions.watchPending().listen((pending) {
if (!isClosed) {
emit(state._with(pending: pending));
}
});
await _read();
}
void delete(String id) => _deletions.delete(id);
void undo(String id) => _deletions.undo(id);
Future<void> _read() async {
final all = await _plans.all();
final opened =
[
for (final plan in all)
if (plan.content != null) plan,
]..sort(
(a, b) => a.content!.name.toLowerCase().compareTo(
b.content!.name.toLowerCase(),
),
);
if (!isClosed) {
emit(
state._with(
all: [
...opened,
for (final plan in all)
if (plan.content == null) plan,
],
),
);
}
}
@override
Future<void> close() async {
await _changes?.cancel();
await _pending?.cancel();
await super.close();
}
}
final class const StrengthPlansState({
final bool loaded = false,
final List<StoredStrengthPlan> _all = const [],
final Set<String> _pending = const {},
}) with Equatable {
/// The plans to list: all but those being deleted.
List<StoredStrengthPlan> get visible => [
for (final plan in _all)
if (!_pending.contains(plan.id)) plan,
];
StrengthPlansState _with({
List<StoredStrengthPlan>? all,
Set<String>? pending,
}) => StrengthPlansState(
loaded: loaded || all != null,
all: all ?? _all,
pending: pending ?? _pending,
);
@override
List<Object?> get props => [loaded, _all, _pending];
}

View file

@ -0,0 +1,188 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_session_store.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_done.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_session.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
/// Where a session is.
enum SessionPhase() {
running,
saving,
saved,
abandoned,
}
/// A strength session under way (ADR 0035, D3): each set done as typed
/// over the plan, skipped, or its exercise swapped (D4); the rest counted
/// down a second at a time, [StrengthSessionState.restsEnded] counting up as
/// each rest runs out; kept in [_store] after every change; saved as a
/// workout or let go.
class StrengthSessionCubit(
final StrengthSessionStore _store,
final WorkoutRepository _workouts, {
required StrengthSession session,
/// The place it is done at, whose equipment makes the weights up; null
/// where none is chosen.
final PlaceContent? place,
}) extends Cubit<StrengthSessionState> {
this
: super(
StrengthSessionState(session: session, place: place, now: clock.now()),
);
Timer? _ticker;
/// Keeps the session as it starts, and follows a rest kept with it.
Future<void> open() async {
await _store.write(state.session);
_follow();
}
/// The next set done as [reps] repetitions or [seconds], at [grams].
Future<void> done({int? reps, int? seconds, int? grams}) async {
final at = state.session.next;
if (at == null) {
return;
}
final now = clock.now().toUtc();
await _change(
state.session.withDone(
DoneSet(
exercise: state.session.exerciseAt(at),
reps: reps,
seconds: seconds,
grams: grams,
doneAt: now,
),
now,
),
);
}
Future<void> skip() => _change(state.session.withSkipped());
/// The next set's exercise swapped for [exercise] for the rest of the
/// session.
Future<void> swap(Exercise exercise) async {
final at = state.session.next;
if (at != null) {
await _change(state.session.withSwap(at.block, at.exercise, exercise));
}
}
Future<void> skipRest() => _change(state.session.withoutRest());
/// Saves what was done as a workout, ended now, and lets the session go;
/// the workout's id, or null for one with nothing done.
Future<String?> finish() async {
if (state.session.done.isEmpty) {
await abandon();
return null;
}
emit(state._with(phase: SessionPhase.saving));
final id = await _workouts.save(
state.session.toWorkout(clock.now().toUtc()),
const [],
);
await _store.clear();
_ticker?.cancel();
emit(state._with(phase: SessionPhase.saved, workoutId: id));
return id;
}
/// Lets the session go without saving it.
Future<void> abandon() async {
await _store.clear();
_ticker?.cancel();
emit(state._with(phase: SessionPhase.abandoned));
}
Future<void> _change(StrengthSession session) async {
emit(state._with(session: session, now: clock.now()));
await _store.write(session);
_follow();
}
/// Ticks while a rest runs, once more as it ends.
void _follow() {
_ticker?.cancel();
if (!state.session.restsAt(clock.now())) {
return;
}
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
final now = clock.now();
if (isClosed) {
return;
}
if (state.session.restsAt(now)) {
emit(state._with(now: now));
} else {
_ticker?.cancel();
emit(
state._with(
session: state.session.withoutRest(),
now: now,
restsEnded: state.restsEnded + 1,
),
);
unawaited(_store.write(state.session));
}
});
}
@override
Future<void> close() async {
_ticker?.cancel();
await super.close();
}
}
final class const StrengthSessionState({
required final StrengthSession session,
required final PlaceContent? place,
required final DateTime now,
final SessionPhase phase = SessionPhase.running,
final String? workoutId,
/// How many rests ran out while followed, so the screen rings as it
/// counts up.
final int restsEnded = 0,
}) with Equatable {
/// What is left of the rest; null while none runs.
Duration? get restLeft => switch (session.restUntil) {
final until? when until.isAfter(now) => until.difference(now),
_ => null,
};
StrengthSessionState _with({
StrengthSession? session,
DateTime? now,
SessionPhase? phase,
String? workoutId,
int? restsEnded,
}) => StrengthSessionState(
session: session ?? this.session,
place: place,
now: now ?? this.now,
phase: phase ?? this.phase,
workoutId: workoutId ?? this.workoutId,
restsEnded: restsEnded ?? this.restsEnded,
);
@override
List<Object?> get props => [
session,
place,
now,
phase,
workoutId,
restsEnded,
];
}

View file

@ -0,0 +1,196 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise_catalogue.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_done.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
/// The schema of a strength plan's payload this app writes and reads.
const strengthPlanSchemaVersion = 1;
/// [exercise] as JSON: whole, the catalogue's too, so that an app that
/// doesn't know its id yet can still do it.
Map<String, Object?> exerciseJson(Exercise exercise) => {
'id': ?exercise.id,
'name': ?exercise.name,
if (exercise.needs.isNotEmpty)
'needs': [for (final need in exercise.needs) need.name],
'load': exercise.load.name,
'pattern': exercise.pattern.name,
if (exercise.timed) 'timed': true,
};
/// The exercise [json] holds: the catalogue's as this app knows it, else as
/// written; null where it can't be read. A need this app doesn't know is
/// left out.
Exercise? exerciseOf(Object? json) {
if (json is! Map<String, Object?>) {
return null;
}
if (json['id'] case final String id) {
if (catalogued(id) case final known?) {
return known;
}
}
final load = ExerciseLoad.values.asNameMap()[json['load']];
final pattern = MovementPattern.values.asNameMap()[json['pattern']];
final id = json['id'] as String?;
final name = json['name'] as String?;
if (load == null || pattern == null || (id == null && name == null)) {
return null;
}
return Exercise(
id: id,
name: name,
needs: [
for (final need in json['needs'] as List<Object?>? ?? const [])
?ExerciseNeed.values.asNameMap()[need],
],
load: load,
pattern: pattern,
timed: json['timed'] == true,
);
}
int? _positive(Object? value) => value is int && value > 0 ? value : null;
/// [content] as its row's payload, before sealing (ADR 0035, D5): UTF-8
/// JSON, rests in seconds, weights in grams.
ByteData encodeStrengthPlan(StrengthPlanContent content) =>
ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
jsonEncode({
'schemaVersion': strengthPlanSchemaVersion,
'name': content.name,
'blocks': [
for (final block in content.blocks)
{
'restS': block.rest.inSeconds,
if (block.restBetween > Duration.zero)
'restBetweenS': block.restBetween.inSeconds,
'exercises': [
for (final planned in block.exercises)
{
'exercise': exerciseJson(planned.exercise),
'sets': [
for (final set in planned.sets)
{
'reps': ?set.reps,
'seconds': ?set.seconds,
'grams': ?set.grams,
},
],
},
],
},
],
}),
),
),
);
/// The strength plan [payload] holds, opened; null for one of a newer
/// schema or one it can't read. An exercise it can't read is left out with
/// its sets, and a block left without any.
StrengthPlanContent? decodeStrengthPlan(ByteData payload) {
if (payload.lengthInBytes == 0) {
return null;
}
try {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > strengthPlanSchemaVersion) {
log('A strength plan of schema $version skipped', name: 'Strength');
return null;
}
return StrengthPlanContent(
name: json['name']! as String,
blocks: [
for (final block in json['blocks']! as List<Object?>)
if (block case final Map<String, Object?> block)
if (_exercisesOf(block['exercises']) case final exercises
when exercises.isNotEmpty)
StrengthBlock(
exercises: exercises,
rest: Duration(seconds: _positive(block['restS']) ?? 0),
restBetween: Duration(
seconds: _positive(block['restBetweenS']) ?? 0,
),
),
],
);
} on Object catch (error) {
log('A strength plan not read: ${error.runtimeType}', name: 'Strength');
return null;
}
}
List<PlannedExercise> _exercisesOf(Object? json) => [
for (final planned in json is List<Object?> ? json : const <Object?>[])
if (planned case {
'exercise': final Object exercise,
'sets': final List<Object?> sets,
})
if (exerciseOf(exercise) case final exercise?)
PlannedExercise(
exercise: exercise,
sets: [
for (final set in sets)
if (set case final Map<String, Object?> set)
StrengthSet(
reps: _positive(set['reps']),
seconds: _positive(set['seconds']),
grams: _positive(set['grams']),
),
],
),
];
/// [strength] as JSON, as a workout's payload keeps it.
Map<String, Object?> workoutStrengthJson(WorkoutStrength strength) => {
'planName': ?strength.planName,
'placeName': ?strength.placeName,
'sets': [for (final set in strength.sets) doneSetJson(set)],
};
/// [set] as JSON, its time in UTC milliseconds.
Map<String, Object?> doneSetJson(DoneSet set) => {
'exercise': exerciseJson(set.exercise),
'reps': ?set.reps,
'seconds': ?set.seconds,
'grams': ?set.grams,
'at': set.doneAt.toUtc().millisecondsSinceEpoch,
};
/// The set [json] holds; null where it can't be read.
DoneSet? doneSetOf(Object? json) => switch (json) {
{'exercise': final Object exercise, 'at': final int at} &&
final Map<String, Object?> set =>
switch (exerciseOf(exercise)) {
final exercise? => DoneSet(
exercise: exercise,
reps: _positive(set['reps']),
seconds: _positive(set['seconds']),
grams: _positive(set['grams']),
doneAt: DateTime.fromMillisecondsSinceEpoch(at, isUtc: true),
),
null => null,
},
_ => null,
};
/// What a strength workout's [json] holds; a set it can't read left out.
WorkoutStrength? workoutStrengthOf(Object? json) => switch (json) {
{'sets': final List<Object?> sets} && final Map<String, Object?> strength =>
WorkoutStrength(
planName: strength['planName'] as String?,
placeName: strength['placeName'] as String?,
sets: [for (final set in sets) ?doneSetOf(set)],
),
_ => null,
};

View file

@ -0,0 +1,23 @@
import 'package:halkyon_app/halkyon_app.dart';
/// A `strength_plan` row as kept: its id and its envelope, sealed (ADR 0030,
/// D7).
typedef StrengthPlanRow = ({String id, Envelope envelope});
/// Where the person's strength plans are kept: their synced
/// `strength_plan` rows (ADR 0035). The repository seals and opens;
/// this only keeps.
abstract interface class StrengthPlanGateway() {
Future<List<StrengthPlanRow>> plans();
Future<void> insert(StrengthPlanRow plan);
/// Replaces the plan [id]'s envelope (an edit); nothing for one gone.
Future<void> update(String id, Envelope envelope);
/// Deletes the plan [id], its payload blanked first (ADR 0002, rule 2).
Future<void> delete(String id);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,85 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show Uuid;
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_payload.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
/// The person's strength plans (ADR 0035), sealed with their key for
/// their row as workouts are (D7); a readable row is one the instance made
/// up, and shows locked.
class StrengthPlanRepository(
final StrengthPlanGateway _gateway,
final TrainKeys _keys,
) {
StreamSubscription<void>? _remote;
/// This device's changes, and those a sync brings while anyone listens.
late final StreamController<void> _changes = StreamController.broadcast(
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
onCancel: () => _remote?.cancel(),
);
late final _envelope = PayloadEnvelope(_keys.cipher);
/// Fires when the plans changed, here or on the person's other devices.
Stream<void> get changes => _changes.stream;
/// Every plan, in the order they were made; one that doesn't open here
/// with its content null.
Future<List<StoredStrengthPlan>> all() async {
await _keys.refresh();
final rows = await _gateway.plans()
..sort((a, b) => a.id.compareTo(b.id));
return [
for (final row in rows)
StoredStrengthPlan(row.id, switch (_open(row.envelope, row.id)) {
final payload? => decodeStrengthPlan(payload),
null => null,
}),
];
}
/// Keeps a new plan and returns its id; throws [KeysMissing] where this
/// device can't seal.
Future<String> save(StrengthPlanContent content) async {
final id = const Uuid().v7();
await _gateway.insert((id: id, envelope: await _sealed(id, content)));
_changes.add(null);
return id;
}
Future<void> update(String id, StrengthPlanContent content) async {
await _gateway.update(id, await _sealed(id, content));
_changes.add(null);
}
Future<void> delete(String id) async {
await _gateway.delete(id);
_changes.add(null);
}
/// Closes what it holds open.
Future<void> dispose() => _changes.close();
Future<Envelope> _sealed(String id, StrengthPlanContent content) async =>
_envelope.seal(
encodeStrengthPlan(content),
keyId: await _keys.keyToWrite(),
table: 'strength_plan',
id: id,
);
ByteData? _open(Envelope envelope, String id) {
if (envelope.keyId == null) {
return null;
}
return switch (_envelope.open(envelope, table: 'strength_plan', id: id)) {
Readable(:final payload) => payload,
Locked() => null,
};
}
}

View file

@ -0,0 +1,135 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_payload.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_session.dart';
/// The strength session under way on this device (ADR 0035, D3), in the
/// person's `strength_session.json` after every change, so one cut off is
/// offered again; never synced. Without a file (widget tests), kept in
/// memory.
class StrengthSessionStore([final File? _file]) {
String? _memory;
StrengthSession? read() {
final file = _file;
final text = file == null
? _memory
: (file.existsSync() ? file.readAsStringSync() : null);
if (text == null) {
return null;
}
try {
return sessionOf(jsonDecode(text));
} on Object catch (error) {
log('A session not read: ${error.runtimeType}', name: 'Strength');
return null;
}
}
Future<void> write(StrengthSession session) async {
final text = jsonEncode(sessionJson(session));
final file = _file;
if (file == null) {
_memory = text;
return;
}
// Written beside it and moved over it, so a cut-off write never leaves
// half a session.
final next = File('${file.path}.new');
await next.writeAsString(text, flush: true);
await next.rename(file.path);
}
Future<void> clear() async {
_memory = null;
final file = _file;
if (file != null && file.existsSync()) {
await file.delete();
}
}
}
/// [session] as JSON: its plan as a plan's payload writes it, times in UTC
/// milliseconds.
Map<String, Object?> sessionJson(StrengthSession session) => {
'plan': utf8.decode(
Uint8List.sublistView(encodeStrengthPlan(session.plan)),
),
'placeName': ?session.placeName,
'startedAt': session.startedAt.toUtc().millisecondsSinceEpoch,
'done': [
for (final each in session.done)
{
'block': each.at.block,
'exercise': each.at.exercise,
'set': each.at.set,
'done': doneSetJson(each.set),
},
],
'skipped': [
for (final at in session.skipped) [at.block, at.exercise, at.set],
],
'swaps': {
for (final MapEntry(:key, :value) in session.swaps.entries)
key: exerciseJson(value),
},
'restUntil': ?session.restUntil?.toUtc().millisecondsSinceEpoch,
};
/// The session [json] holds; null where it can't be read whole, or where
/// what it names isn't in its plan.
StrengthSession? sessionOf(Object? json) {
if (json is! Map<String, Object?>) {
return null;
}
final plan = switch (json['plan']) {
final String text => decodeStrengthPlan(
ByteData.sublistView(Uint8List.fromList(utf8.encode(text))),
),
_ => null,
};
final startedAt = json['startedAt'];
if (plan == null || startedAt is! int) {
return null;
}
final order = plan.order.toSet();
final done = [
for (final each in json['done'] as List<Object?>? ?? const [])
if (each case {
'block': final int block,
'exercise': final int exercise,
'set': final int set,
'done': final Object done,
})
if (doneSetOf(done) case final done?)
(at: (block: block, exercise: exercise, set: set), set: done),
];
final skipped = {
for (final at in json['skipped'] as List<Object?>? ?? const [])
if (at case [final int block, final int exercise, final int set])
(block: block, exercise: exercise, set: set),
};
if (!done.every((each) => order.contains(each.at)) ||
!skipped.every(order.contains)) {
return null;
}
return StrengthSession(
plan: plan,
placeName: json['placeName'] as String?,
startedAt: DateTime.fromMillisecondsSinceEpoch(startedAt, isUtc: true),
done: done,
skipped: skipped,
swaps: {
if (json['swaps'] case final Map<String, Object?> swaps)
for (final MapEntry(:key, :value) in swaps.entries)
key: ?exerciseOf(value),
},
restUntil: switch (json['restUntil']) {
final int at => DateTime.fromMillisecondsSinceEpoch(at, isUtc: true),
_ => null,
},
);
}

View file

@ -0,0 +1,99 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [StrengthPlanGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncStrengthPlanGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements StrengthPlanGateway {
@override
Future<List<StrengthPlanRow>> plans() async => [
for (final plan in await protocol.StrengthPlan.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: plan.id!.uuid,
envelope: (
keyId: plan.keyId,
nonce: plan.nonce,
payload: plan.payload,
),
),
];
@override
Future<void> insert(StrengthPlanRow plan) async {
await protocol.StrengthPlan.db.insertRow(
_db,
protocol.StrengthPlan(
id: protocol.UuidValue.withValidation(plan.id),
keyId: plan.envelope.keyId,
nonce: plan.envelope.nonce,
payload: plan.envelope.payload,
),
);
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.StrengthPlan.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final plan?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.StrengthPlan.db.updateRow(
_db,
plan.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(String id) async {
final uuid = protocol.UuidValue.withValidation(id);
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
if (await protocol.StrengthPlan.db.findById(
_db,
uuid,
transaction: transaction,
)
case final plan?) {
await protocol.StrengthPlan.db.updateRow(
_db,
protocol.StrengthPlan(
id: plan.id,
spaceId: plan.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.StrengthPlan.db.deleteWhere(
_db,
where: (t) => t.id.equals(uuid),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

Some files were not shown because too many files have changed in this diff Show more