feat(train): interval plans, synced and sealed, with an editor and templates (ADR 0030, D42)
Train's server gains the synced `plan` table, a sealed payload alone; the migration `plans` only adds it, its space index and the foreign key to its sync space, and a person's deletion takes it with their space. The app keeps plans as `plan` rows sealed for their row: a name, what they are for, whether a step off target is told, and blocks of steps repeated, each ending after a time, a distance or a lap tap and aiming at a pace, speed, heart rate or zone, or the rower's split, watts or stroke rate. Home's Plans tab lists them, a new one starts from nothing or from a template (4×4 minutes, Tabata, 10×400 m), the editor changes steps on a sheet, and a deletion asks first and can be undone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
fa7680821d
commit
f406ddcc59
27 changed files with 23608 additions and 10 deletions
|
|
@ -241,6 +241,76 @@
|
|||
"loopFormSave": "Band einrichten",
|
||||
"homeWorkouts": "Workouts",
|
||||
"homeDays": "Tage",
|
||||
"homePlans": "Pläne",
|
||||
"plansNew": "Neuer Plan",
|
||||
"plansNewEmpty": "Leer",
|
||||
"plansEmptyTitle": "Noch keine Pläne",
|
||||
"plansEmptyHint": "Die Schritte eines Plans führen dich beim Aufzeichnen durch Intervalle, mit Ton, Vibration und Stimme bei jedem Wechsel. Beginne mit einer Vorlage oder leer.",
|
||||
"planLocked": "Ein Plan, der sich hier nicht öffnet",
|
||||
"planTemplateFourByFour": "4×4 Minuten",
|
||||
"planTemplateTabata": "Tabata",
|
||||
"planTemplateFourHundreds": "10×400 m",
|
||||
"planTitle": "Plan",
|
||||
"planNewTitle": "Neuer Plan",
|
||||
"planName": "Name",
|
||||
"planActivity": "Für",
|
||||
"planActivityAny": "Jede Aktivität",
|
||||
"planOffTarget": "Sagen, wenn ich vom Ziel abweiche",
|
||||
"planOffTargetHint": "Ein kurzer Hinweis, höchstens alle 30 Sekunden.",
|
||||
"planSave": "Speichern",
|
||||
"planNotSaved": "Der Plan wurde nicht gespeichert.",
|
||||
"planAddStep": "Schritt hinzufügen",
|
||||
"planAddRepeat": "Wiederholung hinzufügen",
|
||||
"planRepeat": "{count}×",
|
||||
"planFewer": "Weniger Runden",
|
||||
"planMore": "Mehr Runden",
|
||||
"planBlockMenu": "Mehr",
|
||||
"planMoveUp": "Nach oben",
|
||||
"planMoveDown": "Nach unten",
|
||||
"planRemove": "Entfernen",
|
||||
"planDelete": "Löschen",
|
||||
"planDeleteTitle": "Diesen Plan löschen?",
|
||||
"planDeleteMessage": "Er wird auf all deinen Geräten gelöscht; Trainings, die mit ihm aufgezeichnet wurden, behalten ihre Schritte.",
|
||||
"planDeleted": "Plan gelöscht",
|
||||
"planSteps": "{count, plural, =1{1 Schritt} other{{count} Schritte}}",
|
||||
"stepTitle": "Schritt",
|
||||
"stepKind": "Art",
|
||||
"stepWarmUp": "Aufwärmen",
|
||||
"stepWork": "Belastung",
|
||||
"stepRecovery": "Erholung",
|
||||
"stepCoolDown": "Auslaufen",
|
||||
"stepEnds": "Endet",
|
||||
"stepEndTime": "Nach einer Zeit",
|
||||
"stepEndDistance": "Nach einer Strecke",
|
||||
"stepEndLap": "Mit der Rundentaste",
|
||||
"stepTimeLabel": "Zeit (min:s)",
|
||||
"stepMetresLabel": "Meter",
|
||||
"stepTarget": "Ziel",
|
||||
"stepZone": "Zone",
|
||||
"stepDone": "Fertig",
|
||||
"stepMinutes": "{minutes} min",
|
||||
"stepSeconds": "{seconds} s",
|
||||
"stepMinutesSeconds": "{minutes} min {seconds} s",
|
||||
"stepMetres": "{metres} m",
|
||||
"stepKilometres": "{km} km",
|
||||
"stepUntilLap": "bis zur Rundentaste",
|
||||
"targetNone": "Keins",
|
||||
"targetFrom": "Von",
|
||||
"targetTo": "Bis",
|
||||
"targetKindPace": "Tempo (pro km)",
|
||||
"targetKindSpeed": "Geschwindigkeit (km/h)",
|
||||
"targetKindHeartRate": "Herzfrequenz (Schläge/min)",
|
||||
"targetKindHeartZone": "Herzfrequenzzone",
|
||||
"targetKindSplit": "Split (pro 500 m)",
|
||||
"targetKindWatts": "Leistung (W)",
|
||||
"targetKindStrokeRate": "Schlagzahl (pro min)",
|
||||
"targetPace": "{from}–{to} pro km",
|
||||
"targetSpeed": "{from}–{to} km/h",
|
||||
"targetHeartRate": "{from}–{to} Schläge/min",
|
||||
"targetZone": "Zone {zone}",
|
||||
"targetSplit": "{from}–{to} pro 500 m",
|
||||
"targetWatts": "{from}–{to} W",
|
||||
"targetStrokeRate": "{from}–{to} Schläge/min",
|
||||
"daysEmptyTitle": "Noch keine Tage",
|
||||
"daysEmptyHint": "Die Tage und Nächte, die dein Polar Loop zählt, erscheinen hier.",
|
||||
"daysSteps": "{steps, plural, =1{1 Schritt} other{{steps} Schritte}}",
|
||||
|
|
|
|||
|
|
@ -300,6 +300,90 @@
|
|||
"loopFormSave": "Set up the band",
|
||||
"homeWorkouts": "Workouts",
|
||||
"homeDays": "Days",
|
||||
"homePlans": "Plans",
|
||||
"plansNew": "New plan",
|
||||
"plansNewEmpty": "From nothing",
|
||||
"plansEmptyTitle": "No plans yet",
|
||||
"plansEmptyHint": "A plan's steps lead you through intervals as you record, with a tone, a vibration and a voice at each change. Start from a template, or from nothing.",
|
||||
"planLocked": "A plan that doesn't open here",
|
||||
"planTemplateFourByFour": "4×4 minutes",
|
||||
"planTemplateTabata": "Tabata",
|
||||
"planTemplateFourHundreds": "10×400 m",
|
||||
"planTitle": "Plan",
|
||||
"planNewTitle": "New plan",
|
||||
"planName": "Name",
|
||||
"planActivity": "For",
|
||||
"planActivityAny": "Any activity",
|
||||
"planOffTarget": "Tell me when I'm off target",
|
||||
"planOffTargetHint": "A short cue at most every 30 seconds.",
|
||||
"planSave": "Save",
|
||||
"planNotSaved": "The plan wasn't saved.",
|
||||
"planAddStep": "Add a step",
|
||||
"planAddRepeat": "Add a repeat",
|
||||
"planRepeat": "{count}×",
|
||||
"@planRepeat": {"placeholders": {"count": {"type": "int"}}},
|
||||
"planFewer": "Fewer rounds",
|
||||
"planMore": "More rounds",
|
||||
"planBlockMenu": "More",
|
||||
"planMoveUp": "Move up",
|
||||
"planMoveDown": "Move down",
|
||||
"planRemove": "Remove",
|
||||
"planDelete": "Delete",
|
||||
"planDeleteTitle": "Delete this plan?",
|
||||
"planDeleteMessage": "It's deleted on all your devices; workouts recorded with it keep their steps.",
|
||||
"planDeleted": "Plan deleted",
|
||||
"planSteps": "{count, plural, =1{1 step} other{{count} steps}}",
|
||||
"@planSteps": {"placeholders": {"count": {"type": "int"}}},
|
||||
"stepTitle": "Step",
|
||||
"stepKind": "Kind",
|
||||
"stepWarmUp": "Warm-up",
|
||||
"stepWork": "Work",
|
||||
"stepRecovery": "Recovery",
|
||||
"stepCoolDown": "Cool-down",
|
||||
"stepEnds": "Ends",
|
||||
"stepEndTime": "After a time",
|
||||
"stepEndDistance": "After a distance",
|
||||
"stepEndLap": "With a lap tap",
|
||||
"stepTimeLabel": "Time (min:s)",
|
||||
"stepMetresLabel": "Metres",
|
||||
"stepTarget": "Target",
|
||||
"stepZone": "Zone",
|
||||
"stepDone": "Done",
|
||||
"stepMinutes": "{minutes} min",
|
||||
"@stepMinutes": {"placeholders": {"minutes": {"type": "int"}}},
|
||||
"stepSeconds": "{seconds} s",
|
||||
"@stepSeconds": {"placeholders": {"seconds": {"type": "int"}}},
|
||||
"stepMinutesSeconds": "{minutes} min {seconds} s",
|
||||
"@stepMinutesSeconds": {"placeholders": {"minutes": {"type": "int"}, "seconds": {"type": "int"}}},
|
||||
"stepMetres": "{metres} m",
|
||||
"@stepMetres": {"placeholders": {"metres": {"type": "int"}}},
|
||||
"stepKilometres": "{km} km",
|
||||
"@stepKilometres": {"placeholders": {"km": {"type": "String"}}},
|
||||
"stepUntilLap": "until a lap tap",
|
||||
"targetNone": "None",
|
||||
"targetFrom": "From",
|
||||
"targetTo": "To",
|
||||
"targetKindPace": "Pace (per km)",
|
||||
"targetKindSpeed": "Speed (km/h)",
|
||||
"targetKindHeartRate": "Heart rate (bpm)",
|
||||
"targetKindHeartZone": "Heart-rate zone",
|
||||
"targetKindSplit": "Split (per 500 m)",
|
||||
"targetKindWatts": "Power (W)",
|
||||
"targetKindStrokeRate": "Stroke rate (per min)",
|
||||
"targetPace": "{from}–{to} per km",
|
||||
"@targetPace": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"targetSpeed": "{from}–{to} km/h",
|
||||
"@targetSpeed": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"targetHeartRate": "{from}–{to} bpm",
|
||||
"@targetHeartRate": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"targetZone": "Zone {zone}",
|
||||
"@targetZone": {"placeholders": {"zone": {"type": "int"}}},
|
||||
"targetSplit": "{from}–{to} per 500 m",
|
||||
"@targetSplit": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"targetWatts": "{from}–{to} W",
|
||||
"@targetWatts": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"targetStrokeRate": "{from}–{to} strokes/min",
|
||||
"@targetStrokeRate": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"daysEmptyTitle": "No days yet",
|
||||
"daysEmptyHint": "The days and nights your Polar Loop counts show here.",
|
||||
"daysSteps": "{steps, plural, =1{1 step} other{{steps} steps}}",
|
||||
|
|
|
|||
|
|
@ -1468,6 +1468,426 @@ abstract class AppLocalizations {
|
|||
/// **'Days'**
|
||||
String get homeDays;
|
||||
|
||||
/// No description provided for @homePlans.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plans'**
|
||||
String get homePlans;
|
||||
|
||||
/// No description provided for @plansNew.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New plan'**
|
||||
String get plansNew;
|
||||
|
||||
/// No description provided for @plansNewEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From nothing'**
|
||||
String get plansNewEmpty;
|
||||
|
||||
/// No description provided for @plansEmptyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No plans yet'**
|
||||
String get plansEmptyTitle;
|
||||
|
||||
/// No description provided for @plansEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A plan\'s steps lead you through intervals as you record, with a tone, a vibration and a voice at each change. Start from a template, or from nothing.'**
|
||||
String get plansEmptyHint;
|
||||
|
||||
/// No description provided for @planLocked.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A plan that doesn\'t open here'**
|
||||
String get planLocked;
|
||||
|
||||
/// No description provided for @planTemplateFourByFour.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'4×4 minutes'**
|
||||
String get planTemplateFourByFour;
|
||||
|
||||
/// No description provided for @planTemplateTabata.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tabata'**
|
||||
String get planTemplateTabata;
|
||||
|
||||
/// No description provided for @planTemplateFourHundreds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'10×400 m'**
|
||||
String get planTemplateFourHundreds;
|
||||
|
||||
/// No description provided for @planTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plan'**
|
||||
String get planTitle;
|
||||
|
||||
/// No description provided for @planNewTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New plan'**
|
||||
String get planNewTitle;
|
||||
|
||||
/// No description provided for @planName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name'**
|
||||
String get planName;
|
||||
|
||||
/// No description provided for @planActivity.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For'**
|
||||
String get planActivity;
|
||||
|
||||
/// No description provided for @planActivityAny.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Any activity'**
|
||||
String get planActivityAny;
|
||||
|
||||
/// No description provided for @planOffTarget.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tell me when I\'m off target'**
|
||||
String get planOffTarget;
|
||||
|
||||
/// No description provided for @planOffTargetHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A short cue at most every 30 seconds.'**
|
||||
String get planOffTargetHint;
|
||||
|
||||
/// No description provided for @planSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get planSave;
|
||||
|
||||
/// No description provided for @planNotSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The plan wasn\'t saved.'**
|
||||
String get planNotSaved;
|
||||
|
||||
/// No description provided for @planAddStep.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a step'**
|
||||
String get planAddStep;
|
||||
|
||||
/// No description provided for @planAddRepeat.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a repeat'**
|
||||
String get planAddRepeat;
|
||||
|
||||
/// No description provided for @planRepeat.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count}×'**
|
||||
String planRepeat(int count);
|
||||
|
||||
/// No description provided for @planFewer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fewer rounds'**
|
||||
String get planFewer;
|
||||
|
||||
/// No description provided for @planMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'More rounds'**
|
||||
String get planMore;
|
||||
|
||||
/// No description provided for @planBlockMenu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'More'**
|
||||
String get planBlockMenu;
|
||||
|
||||
/// No description provided for @planMoveUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Move up'**
|
||||
String get planMoveUp;
|
||||
|
||||
/// No description provided for @planMoveDown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Move down'**
|
||||
String get planMoveDown;
|
||||
|
||||
/// No description provided for @planRemove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove'**
|
||||
String get planRemove;
|
||||
|
||||
/// No description provided for @planDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get planDelete;
|
||||
|
||||
/// No description provided for @planDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete this plan?'**
|
||||
String get planDeleteTitle;
|
||||
|
||||
/// No description provided for @planDeleteMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It\'s deleted on all your devices; workouts recorded with it keep their steps.'**
|
||||
String get planDeleteMessage;
|
||||
|
||||
/// No description provided for @planDeleted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plan deleted'**
|
||||
String get planDeleted;
|
||||
|
||||
/// No description provided for @planSteps.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 step} other{{count} steps}}'**
|
||||
String planSteps(int count);
|
||||
|
||||
/// No description provided for @stepTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Step'**
|
||||
String get stepTitle;
|
||||
|
||||
/// No description provided for @stepKind.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Kind'**
|
||||
String get stepKind;
|
||||
|
||||
/// No description provided for @stepWarmUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Warm-up'**
|
||||
String get stepWarmUp;
|
||||
|
||||
/// No description provided for @stepWork.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Work'**
|
||||
String get stepWork;
|
||||
|
||||
/// No description provided for @stepRecovery.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recovery'**
|
||||
String get stepRecovery;
|
||||
|
||||
/// No description provided for @stepCoolDown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cool-down'**
|
||||
String get stepCoolDown;
|
||||
|
||||
/// No description provided for @stepEnds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ends'**
|
||||
String get stepEnds;
|
||||
|
||||
/// No description provided for @stepEndTime.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'After a time'**
|
||||
String get stepEndTime;
|
||||
|
||||
/// No description provided for @stepEndDistance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'After a distance'**
|
||||
String get stepEndDistance;
|
||||
|
||||
/// No description provided for @stepEndLap.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'With a lap tap'**
|
||||
String get stepEndLap;
|
||||
|
||||
/// No description provided for @stepTimeLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Time (min:s)'**
|
||||
String get stepTimeLabel;
|
||||
|
||||
/// No description provided for @stepMetresLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Metres'**
|
||||
String get stepMetresLabel;
|
||||
|
||||
/// No description provided for @stepTarget.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Target'**
|
||||
String get stepTarget;
|
||||
|
||||
/// No description provided for @stepZone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Zone'**
|
||||
String get stepZone;
|
||||
|
||||
/// No description provided for @stepDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get stepDone;
|
||||
|
||||
/// No description provided for @stepMinutes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{minutes} min'**
|
||||
String stepMinutes(int minutes);
|
||||
|
||||
/// No description provided for @stepSeconds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{seconds} s'**
|
||||
String stepSeconds(int seconds);
|
||||
|
||||
/// No description provided for @stepMinutesSeconds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{minutes} min {seconds} s'**
|
||||
String stepMinutesSeconds(int minutes, int seconds);
|
||||
|
||||
/// No description provided for @stepMetres.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{metres} m'**
|
||||
String stepMetres(int metres);
|
||||
|
||||
/// No description provided for @stepKilometres.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{km} km'**
|
||||
String stepKilometres(String km);
|
||||
|
||||
/// No description provided for @stepUntilLap.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'until a lap tap'**
|
||||
String get stepUntilLap;
|
||||
|
||||
/// No description provided for @targetNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None'**
|
||||
String get targetNone;
|
||||
|
||||
/// No description provided for @targetFrom.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From'**
|
||||
String get targetFrom;
|
||||
|
||||
/// No description provided for @targetTo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'To'**
|
||||
String get targetTo;
|
||||
|
||||
/// No description provided for @targetKindPace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pace (per km)'**
|
||||
String get targetKindPace;
|
||||
|
||||
/// No description provided for @targetKindSpeed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Speed (km/h)'**
|
||||
String get targetKindSpeed;
|
||||
|
||||
/// No description provided for @targetKindHeartRate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Heart rate (bpm)'**
|
||||
String get targetKindHeartRate;
|
||||
|
||||
/// No description provided for @targetKindHeartZone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Heart-rate zone'**
|
||||
String get targetKindHeartZone;
|
||||
|
||||
/// No description provided for @targetKindSplit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Split (per 500 m)'**
|
||||
String get targetKindSplit;
|
||||
|
||||
/// No description provided for @targetKindWatts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Power (W)'**
|
||||
String get targetKindWatts;
|
||||
|
||||
/// No description provided for @targetKindStrokeRate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stroke rate (per min)'**
|
||||
String get targetKindStrokeRate;
|
||||
|
||||
/// No description provided for @targetPace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from}–{to} per km'**
|
||||
String targetPace(String from, String to);
|
||||
|
||||
/// No description provided for @targetSpeed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from}–{to} km/h'**
|
||||
String targetSpeed(String from, String to);
|
||||
|
||||
/// No description provided for @targetHeartRate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from}–{to} bpm'**
|
||||
String targetHeartRate(String from, String to);
|
||||
|
||||
/// No description provided for @targetZone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Zone {zone}'**
|
||||
String targetZone(int zone);
|
||||
|
||||
/// No description provided for @targetSplit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from}–{to} per 500 m'**
|
||||
String targetSplit(String from, String to);
|
||||
|
||||
/// No description provided for @targetWatts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from}–{to} W'**
|
||||
String targetWatts(String from, String to);
|
||||
|
||||
/// No description provided for @targetStrokeRate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from}–{to} strokes/min'**
|
||||
String targetStrokeRate(String from, String to);
|
||||
|
||||
/// No description provided for @daysEmptyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -834,6 +834,253 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get homeDays => 'Tage';
|
||||
|
||||
@override
|
||||
String get homePlans => 'Pläne';
|
||||
|
||||
@override
|
||||
String get plansNew => 'Neuer Plan';
|
||||
|
||||
@override
|
||||
String get plansNewEmpty => 'Leer';
|
||||
|
||||
@override
|
||||
String get plansEmptyTitle => 'Noch keine Pläne';
|
||||
|
||||
@override
|
||||
String get plansEmptyHint =>
|
||||
'Die Schritte eines Plans führen dich beim Aufzeichnen durch Intervalle, mit Ton, Vibration und Stimme bei jedem Wechsel. Beginne mit einer Vorlage oder leer.';
|
||||
|
||||
@override
|
||||
String get planLocked => 'Ein Plan, der sich hier nicht öffnet';
|
||||
|
||||
@override
|
||||
String get planTemplateFourByFour => '4×4 Minuten';
|
||||
|
||||
@override
|
||||
String get planTemplateTabata => 'Tabata';
|
||||
|
||||
@override
|
||||
String get planTemplateFourHundreds => '10×400 m';
|
||||
|
||||
@override
|
||||
String get planTitle => 'Plan';
|
||||
|
||||
@override
|
||||
String get planNewTitle => 'Neuer Plan';
|
||||
|
||||
@override
|
||||
String get planName => 'Name';
|
||||
|
||||
@override
|
||||
String get planActivity => 'Für';
|
||||
|
||||
@override
|
||||
String get planActivityAny => 'Jede Aktivität';
|
||||
|
||||
@override
|
||||
String get planOffTarget => 'Sagen, wenn ich vom Ziel abweiche';
|
||||
|
||||
@override
|
||||
String get planOffTargetHint =>
|
||||
'Ein kurzer Hinweis, höchstens alle 30 Sekunden.';
|
||||
|
||||
@override
|
||||
String get planSave => 'Speichern';
|
||||
|
||||
@override
|
||||
String get planNotSaved => 'Der Plan wurde nicht gespeichert.';
|
||||
|
||||
@override
|
||||
String get planAddStep => 'Schritt hinzufügen';
|
||||
|
||||
@override
|
||||
String get planAddRepeat => 'Wiederholung hinzufügen';
|
||||
|
||||
@override
|
||||
String planRepeat(int count) {
|
||||
return '$count×';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planFewer => 'Weniger Runden';
|
||||
|
||||
@override
|
||||
String get planMore => 'Mehr Runden';
|
||||
|
||||
@override
|
||||
String get planBlockMenu => 'Mehr';
|
||||
|
||||
@override
|
||||
String get planMoveUp => 'Nach oben';
|
||||
|
||||
@override
|
||||
String get planMoveDown => 'Nach unten';
|
||||
|
||||
@override
|
||||
String get planRemove => 'Entfernen';
|
||||
|
||||
@override
|
||||
String get planDelete => 'Löschen';
|
||||
|
||||
@override
|
||||
String get planDeleteTitle => 'Diesen Plan löschen?';
|
||||
|
||||
@override
|
||||
String get planDeleteMessage =>
|
||||
'Er wird auf all deinen Geräten gelöscht; Trainings, die mit ihm aufgezeichnet wurden, behalten ihre Schritte.';
|
||||
|
||||
@override
|
||||
String get planDeleted => 'Plan gelöscht';
|
||||
|
||||
@override
|
||||
String planSteps(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Schritte',
|
||||
one: '1 Schritt',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepTitle => 'Schritt';
|
||||
|
||||
@override
|
||||
String get stepKind => 'Art';
|
||||
|
||||
@override
|
||||
String get stepWarmUp => 'Aufwärmen';
|
||||
|
||||
@override
|
||||
String get stepWork => 'Belastung';
|
||||
|
||||
@override
|
||||
String get stepRecovery => 'Erholung';
|
||||
|
||||
@override
|
||||
String get stepCoolDown => 'Auslaufen';
|
||||
|
||||
@override
|
||||
String get stepEnds => 'Endet';
|
||||
|
||||
@override
|
||||
String get stepEndTime => 'Nach einer Zeit';
|
||||
|
||||
@override
|
||||
String get stepEndDistance => 'Nach einer Strecke';
|
||||
|
||||
@override
|
||||
String get stepEndLap => 'Mit der Rundentaste';
|
||||
|
||||
@override
|
||||
String get stepTimeLabel => 'Zeit (min:s)';
|
||||
|
||||
@override
|
||||
String get stepMetresLabel => 'Meter';
|
||||
|
||||
@override
|
||||
String get stepTarget => 'Ziel';
|
||||
|
||||
@override
|
||||
String get stepZone => 'Zone';
|
||||
|
||||
@override
|
||||
String get stepDone => 'Fertig';
|
||||
|
||||
@override
|
||||
String stepMinutes(int minutes) {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String stepSeconds(int seconds) {
|
||||
return '$seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String stepMinutesSeconds(int minutes, int seconds) {
|
||||
return '$minutes min $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String stepMetres(int metres) {
|
||||
return '$metres m';
|
||||
}
|
||||
|
||||
@override
|
||||
String stepKilometres(String km) {
|
||||
return '$km km';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepUntilLap => 'bis zur Rundentaste';
|
||||
|
||||
@override
|
||||
String get targetNone => 'Keins';
|
||||
|
||||
@override
|
||||
String get targetFrom => 'Von';
|
||||
|
||||
@override
|
||||
String get targetTo => 'Bis';
|
||||
|
||||
@override
|
||||
String get targetKindPace => 'Tempo (pro km)';
|
||||
|
||||
@override
|
||||
String get targetKindSpeed => 'Geschwindigkeit (km/h)';
|
||||
|
||||
@override
|
||||
String get targetKindHeartRate => 'Herzfrequenz (Schläge/min)';
|
||||
|
||||
@override
|
||||
String get targetKindHeartZone => 'Herzfrequenzzone';
|
||||
|
||||
@override
|
||||
String get targetKindSplit => 'Split (pro 500 m)';
|
||||
|
||||
@override
|
||||
String get targetKindWatts => 'Leistung (W)';
|
||||
|
||||
@override
|
||||
String get targetKindStrokeRate => 'Schlagzahl (pro min)';
|
||||
|
||||
@override
|
||||
String targetPace(String from, String to) {
|
||||
return '$from–$to pro km';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetSpeed(String from, String to) {
|
||||
return '$from–$to km/h';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetHeartRate(String from, String to) {
|
||||
return '$from–$to Schläge/min';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetZone(int zone) {
|
||||
return 'Zone $zone';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetSplit(String from, String to) {
|
||||
return '$from–$to pro 500 m';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetWatts(String from, String to) {
|
||||
return '$from–$to W';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetStrokeRate(String from, String to) {
|
||||
return '$from–$to Schläge/min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get daysEmptyTitle => 'Noch keine Tage';
|
||||
|
||||
|
|
|
|||
|
|
@ -828,6 +828,252 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get homeDays => 'Days';
|
||||
|
||||
@override
|
||||
String get homePlans => 'Plans';
|
||||
|
||||
@override
|
||||
String get plansNew => 'New plan';
|
||||
|
||||
@override
|
||||
String get plansNewEmpty => 'From nothing';
|
||||
|
||||
@override
|
||||
String get plansEmptyTitle => 'No plans yet';
|
||||
|
||||
@override
|
||||
String get plansEmptyHint =>
|
||||
'A plan\'s steps lead you through intervals as you record, with a tone, a vibration and a voice at each change. Start from a template, or from nothing.';
|
||||
|
||||
@override
|
||||
String get planLocked => 'A plan that doesn\'t open here';
|
||||
|
||||
@override
|
||||
String get planTemplateFourByFour => '4×4 minutes';
|
||||
|
||||
@override
|
||||
String get planTemplateTabata => 'Tabata';
|
||||
|
||||
@override
|
||||
String get planTemplateFourHundreds => '10×400 m';
|
||||
|
||||
@override
|
||||
String get planTitle => 'Plan';
|
||||
|
||||
@override
|
||||
String get planNewTitle => 'New plan';
|
||||
|
||||
@override
|
||||
String get planName => 'Name';
|
||||
|
||||
@override
|
||||
String get planActivity => 'For';
|
||||
|
||||
@override
|
||||
String get planActivityAny => 'Any activity';
|
||||
|
||||
@override
|
||||
String get planOffTarget => 'Tell me when I\'m off target';
|
||||
|
||||
@override
|
||||
String get planOffTargetHint => 'A short cue at most every 30 seconds.';
|
||||
|
||||
@override
|
||||
String get planSave => 'Save';
|
||||
|
||||
@override
|
||||
String get planNotSaved => 'The plan wasn\'t saved.';
|
||||
|
||||
@override
|
||||
String get planAddStep => 'Add a step';
|
||||
|
||||
@override
|
||||
String get planAddRepeat => 'Add a repeat';
|
||||
|
||||
@override
|
||||
String planRepeat(int count) {
|
||||
return '$count×';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planFewer => 'Fewer rounds';
|
||||
|
||||
@override
|
||||
String get planMore => 'More rounds';
|
||||
|
||||
@override
|
||||
String get planBlockMenu => 'More';
|
||||
|
||||
@override
|
||||
String get planMoveUp => 'Move up';
|
||||
|
||||
@override
|
||||
String get planMoveDown => 'Move down';
|
||||
|
||||
@override
|
||||
String get planRemove => 'Remove';
|
||||
|
||||
@override
|
||||
String get planDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get planDeleteTitle => 'Delete this plan?';
|
||||
|
||||
@override
|
||||
String get planDeleteMessage =>
|
||||
'It\'s deleted on all your devices; workouts recorded with it keep their steps.';
|
||||
|
||||
@override
|
||||
String get planDeleted => 'Plan deleted';
|
||||
|
||||
@override
|
||||
String planSteps(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count steps',
|
||||
one: '1 step',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepTitle => 'Step';
|
||||
|
||||
@override
|
||||
String get stepKind => 'Kind';
|
||||
|
||||
@override
|
||||
String get stepWarmUp => 'Warm-up';
|
||||
|
||||
@override
|
||||
String get stepWork => 'Work';
|
||||
|
||||
@override
|
||||
String get stepRecovery => 'Recovery';
|
||||
|
||||
@override
|
||||
String get stepCoolDown => 'Cool-down';
|
||||
|
||||
@override
|
||||
String get stepEnds => 'Ends';
|
||||
|
||||
@override
|
||||
String get stepEndTime => 'After a time';
|
||||
|
||||
@override
|
||||
String get stepEndDistance => 'After a distance';
|
||||
|
||||
@override
|
||||
String get stepEndLap => 'With a lap tap';
|
||||
|
||||
@override
|
||||
String get stepTimeLabel => 'Time (min:s)';
|
||||
|
||||
@override
|
||||
String get stepMetresLabel => 'Metres';
|
||||
|
||||
@override
|
||||
String get stepTarget => 'Target';
|
||||
|
||||
@override
|
||||
String get stepZone => 'Zone';
|
||||
|
||||
@override
|
||||
String get stepDone => 'Done';
|
||||
|
||||
@override
|
||||
String stepMinutes(int minutes) {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String stepSeconds(int seconds) {
|
||||
return '$seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String stepMinutesSeconds(int minutes, int seconds) {
|
||||
return '$minutes min $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String stepMetres(int metres) {
|
||||
return '$metres m';
|
||||
}
|
||||
|
||||
@override
|
||||
String stepKilometres(String km) {
|
||||
return '$km km';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepUntilLap => 'until a lap tap';
|
||||
|
||||
@override
|
||||
String get targetNone => 'None';
|
||||
|
||||
@override
|
||||
String get targetFrom => 'From';
|
||||
|
||||
@override
|
||||
String get targetTo => 'To';
|
||||
|
||||
@override
|
||||
String get targetKindPace => 'Pace (per km)';
|
||||
|
||||
@override
|
||||
String get targetKindSpeed => 'Speed (km/h)';
|
||||
|
||||
@override
|
||||
String get targetKindHeartRate => 'Heart rate (bpm)';
|
||||
|
||||
@override
|
||||
String get targetKindHeartZone => 'Heart-rate zone';
|
||||
|
||||
@override
|
||||
String get targetKindSplit => 'Split (per 500 m)';
|
||||
|
||||
@override
|
||||
String get targetKindWatts => 'Power (W)';
|
||||
|
||||
@override
|
||||
String get targetKindStrokeRate => 'Stroke rate (per min)';
|
||||
|
||||
@override
|
||||
String targetPace(String from, String to) {
|
||||
return '$from–$to per km';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetSpeed(String from, String to) {
|
||||
return '$from–$to km/h';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetHeartRate(String from, String to) {
|
||||
return '$from–$to bpm';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetZone(int zone) {
|
||||
return 'Zone $zone';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetSplit(String from, String to) {
|
||||
return '$from–$to per 500 m';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetWatts(String from, String to) {
|
||||
return '$from–$to W';
|
||||
}
|
||||
|
||||
@override
|
||||
String targetStrokeRate(String from, String to) {
|
||||
return '$from–$to strokes/min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get daysEmptyTitle => 'No days yet';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,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/feature/day/bloc/days_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
|
||||
|
|
@ -91,6 +92,12 @@ class const TrainPersonApp({
|
|||
return DaysCubit(train.days, train.loop)..open();
|
||||
},
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
return PlansCubit(train.plans, train.planDeletions)..open();
|
||||
},
|
||||
),
|
||||
],
|
||||
child: _Recording(child: child!),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
|
||||
|
|
@ -62,6 +64,9 @@ class TrainPerson(
|
|||
/// every device of theirs, read on the phone holding the band.
|
||||
required final DayRepository days,
|
||||
|
||||
/// Their interval plans (D42), sealed and synced.
|
||||
required final PlanRepository plans,
|
||||
|
||||
/// The instance's map source (D22).
|
||||
required final MapSourceRepository maps,
|
||||
|
||||
|
|
@ -85,10 +90,14 @@ class TrainPerson(
|
|||
/// person's data, the workouts then kept.
|
||||
late final deletions = DelayedDeletions(workouts.delete, logName: 'Workouts');
|
||||
|
||||
/// Plans deleted, to be undone for a moment, as workouts are.
|
||||
late final planDeletions = DelayedDeletions(plans.delete, logName: 'Plans');
|
||||
|
||||
/// Closes what was opened for the person.
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await deletions.dispose();
|
||||
await planDeletions.dispose();
|
||||
await health?.dispose();
|
||||
await loop?.dispose();
|
||||
await _onDispose?.call();
|
||||
|
|
@ -330,6 +339,7 @@ Future<TrainPerson> _openPersonData(
|
|||
keys,
|
||||
person: person.authUserId,
|
||||
);
|
||||
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
|
||||
// Writes what changed while the app was closed, as it opens (D31).
|
||||
final health = Platform.isAndroid
|
||||
? (HealthConnect(
|
||||
|
|
@ -356,6 +366,7 @@ Future<TrainPerson> _openPersonData(
|
|||
workouts: workouts,
|
||||
profile: profile,
|
||||
days: days,
|
||||
plans: plans,
|
||||
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
|
||||
recording: recorder != null
|
||||
? (
|
||||
|
|
@ -372,6 +383,7 @@ Future<TrainPerson> _openPersonData(
|
|||
await workouts.dispose();
|
||||
await profile.dispose();
|
||||
await days.dispose();
|
||||
await plans.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// What a new step is, and a new repeat: a minute's work, and four rounds
|
||||
/// of a minute's work and a minute's recovery.
|
||||
const _newStep = PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(minutes: 1)),
|
||||
);
|
||||
const _newRepeat = PlanBlock(
|
||||
repeat: 4,
|
||||
steps: [
|
||||
_newStep,
|
||||
PlanStep(kind: StepKind.recovery, end: EndAfterTime(Duration(minutes: 1))),
|
||||
],
|
||||
);
|
||||
|
||||
/// A plan being made or changed (ADR 0030, D42): its name, what it is for,
|
||||
/// its blocks and steps, kept once saved; [id] null for a new one.
|
||||
class PlanEditorCubit(
|
||||
final PlanRepository _plans, {
|
||||
required final PlanContent plan,
|
||||
final String? id,
|
||||
}) extends Cubit<PlanEditorState> {
|
||||
this : super(PlanEditorState(plan: plan, id: id));
|
||||
|
||||
void rename(String name) => _edit(
|
||||
(plan) => _plan(plan, name: name),
|
||||
);
|
||||
|
||||
void setActivity(Activity? activity) => _edit(
|
||||
(plan) => PlanContent(
|
||||
name: plan.name,
|
||||
blocks: plan.blocks,
|
||||
activity: activity,
|
||||
offTargetCues: plan.offTargetCues,
|
||||
),
|
||||
);
|
||||
|
||||
void setOffTargetCues(bool on) =>
|
||||
_edit((plan) => _plan(plan, offTargetCues: on));
|
||||
|
||||
/// Adds a step: at the end of block [block], or as a block of its own.
|
||||
void addStep({int? block}) => _blocks((blocks) {
|
||||
if (block == null) {
|
||||
blocks.add(const PlanBlock(steps: [_newStep]));
|
||||
} else {
|
||||
blocks[block] = PlanBlock(
|
||||
repeat: blocks[block].repeat,
|
||||
steps: [...blocks[block].steps, _newStep],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void addRepeat() => _blocks((blocks) => blocks.add(_newRepeat));
|
||||
|
||||
void setStep(int block, int index, PlanStep step) => _blocks((blocks) {
|
||||
blocks[block] = PlanBlock(
|
||||
repeat: blocks[block].repeat,
|
||||
steps: [...blocks[block].steps]..[index] = step,
|
||||
);
|
||||
});
|
||||
|
||||
/// Removes a step; its block goes with its last.
|
||||
void removeStep(int block, int index) => _blocks((blocks) {
|
||||
final steps = [...blocks[block].steps]..removeAt(index);
|
||||
if (steps.isEmpty) {
|
||||
blocks.removeAt(block);
|
||||
} else {
|
||||
blocks[block] = PlanBlock(repeat: blocks[block].repeat, steps: steps);
|
||||
}
|
||||
});
|
||||
|
||||
/// Sets how often block [block] runs, once at least.
|
||||
void setRepeat(int block, int repeat) => _blocks((blocks) {
|
||||
blocks[block] = PlanBlock(
|
||||
repeat: repeat < 1 ? 1 : repeat,
|
||||
steps: blocks[block].steps,
|
||||
);
|
||||
});
|
||||
|
||||
void removeBlock(int block) => _blocks((blocks) => blocks.removeAt(block));
|
||||
|
||||
/// Moves block [block] up or down by one.
|
||||
void moveBlock(int block, {required bool up}) => _blocks((blocks) {
|
||||
final to = up ? block - 1 : block + 1;
|
||||
if (to >= 0 && to < blocks.length) {
|
||||
blocks.insert(to, blocks.removeAt(block));
|
||||
}
|
||||
});
|
||||
|
||||
/// Keeps the plan; false where it can't be (no name, no step, or this
|
||||
/// device can't seal).
|
||||
Future<bool> save() async {
|
||||
final plan = state.plan;
|
||||
if (!state.canSave) {
|
||||
return false;
|
||||
}
|
||||
final named = _plan(plan, name: plan.name.trim());
|
||||
try {
|
||||
if (state.id case final id?) {
|
||||
await _plans.update(id, named);
|
||||
} else {
|
||||
emit(PlanEditorState(plan: named, id: await _plans.save(named)));
|
||||
}
|
||||
} on KeysMissing {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _blocks(void Function(List<PlanBlock> blocks) change) => _edit((plan) {
|
||||
final blocks = [...plan.blocks];
|
||||
change(blocks);
|
||||
return PlanContent(
|
||||
name: plan.name,
|
||||
blocks: blocks,
|
||||
activity: plan.activity,
|
||||
offTargetCues: plan.offTargetCues,
|
||||
);
|
||||
});
|
||||
|
||||
void _edit(PlanContent Function(PlanContent plan) change) =>
|
||||
emit(PlanEditorState(plan: change(state.plan), id: state.id));
|
||||
|
||||
static PlanContent _plan(
|
||||
PlanContent plan, {
|
||||
String? name,
|
||||
bool? offTargetCues,
|
||||
}) => PlanContent(
|
||||
name: name ?? plan.name,
|
||||
blocks: plan.blocks,
|
||||
activity: plan.activity,
|
||||
offTargetCues: offTargetCues ?? plan.offTargetCues,
|
||||
);
|
||||
}
|
||||
|
||||
final class const PlanEditorState({
|
||||
required final PlanContent plan,
|
||||
final String? id,
|
||||
}) with Equatable {
|
||||
/// Whether it has what a plan needs: a name and a step.
|
||||
bool get canSave => plan.name.trim().isNotEmpty && plan.blocks.isNotEmpty;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [plan, id];
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
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/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
|
||||
|
||||
/// The person's plans as home lists them (ADR 0030, D42): 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 PlansCubit(
|
||||
final PlanRepository _plans,
|
||||
final DelayedDeletions _deletions,
|
||||
) extends Cubit<PlansState> {
|
||||
this : super(const PlansState());
|
||||
|
||||
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 PlansState({
|
||||
final bool loaded = false,
|
||||
final List<StoredPlan> _all = const [],
|
||||
final Set<String> _pending = const {},
|
||||
}) with Equatable {
|
||||
/// The plans to list: all but those being deleted.
|
||||
List<StoredPlan> get visible => [
|
||||
for (final plan in _all)
|
||||
if (!_pending.contains(plan.id)) plan,
|
||||
];
|
||||
|
||||
PlansState _with({List<StoredPlan>? all, Set<String>? pending}) => PlansState(
|
||||
loaded: loaded || all != null,
|
||||
all: all ?? _all,
|
||||
pending: pending ?? _pending,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [loaded, _all, _pending];
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// A `plan` row as kept: its id and its envelope, sealed (ADR 0030, D7).
|
||||
typedef PlanRow = ({String id, Envelope envelope});
|
||||
|
||||
/// Where the person's interval plans are kept: their synced `plan` rows
|
||||
/// (D42). The repository seals and opens; this only keeps.
|
||||
abstract interface class PlanGateway() {
|
||||
Future<List<PlanRow>> plans();
|
||||
|
||||
Future<void> insert(PlanRow 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// The schema of a plan's payload this app writes and reads.
|
||||
const planSchemaVersion = 1;
|
||||
|
||||
/// [content] as its row's payload, before sealing: UTF-8 JSON, a step's end
|
||||
/// as `{by: time, seconds}`, `{by: distance, metres}` or `{by: lap}`.
|
||||
ByteData encodePlan(PlanContent content) => ByteData.sublistView(
|
||||
Uint8List.fromList(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'schemaVersion': planSchemaVersion,
|
||||
'name': content.name,
|
||||
'activity': ?content.activity?.name,
|
||||
'offTargetCues': content.offTargetCues,
|
||||
'blocks': [
|
||||
for (final block in content.blocks)
|
||||
{
|
||||
'repeat': block.repeat,
|
||||
'steps': [for (final step in block.steps) planStepJson(step)],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// [step] as JSON, as a plan's payload and the recorder take it.
|
||||
Map<String, Object?> planStepJson(PlanStep step) => {
|
||||
'kind': step.kind.name,
|
||||
'end': switch (step.end) {
|
||||
EndAfterTime(:final time) => {'by': 'time', 'seconds': time.inSeconds},
|
||||
EndAfterDistance(:final metres) => {'by': 'distance', 'metres': metres},
|
||||
EndWithLap() => {'by': 'lap'},
|
||||
},
|
||||
if (step.target case final target?)
|
||||
'target': {
|
||||
'kind': target.kind.name,
|
||||
'low': target.low,
|
||||
'high': target.high,
|
||||
},
|
||||
};
|
||||
|
||||
/// The plan [payload] holds, opened; null for one of a newer schema or one
|
||||
/// it can't read. A step it can't read is left out; a target it doesn't
|
||||
/// know is none.
|
||||
PlanContent? decodePlan(ByteData payload) {
|
||||
try {
|
||||
final json = jsonDecode(
|
||||
utf8.decode(Uint8List.sublistView(payload)),
|
||||
) as Map<String, Object?>;
|
||||
final version = json['schemaVersion']! as int;
|
||||
if (version > planSchemaVersion) {
|
||||
log('A plan of schema $version skipped', name: 'Plan');
|
||||
return null;
|
||||
}
|
||||
return PlanContent(
|
||||
name: json['name']! as String,
|
||||
activity: Activity.values.asNameMap()[json['activity']],
|
||||
offTargetCues: json['offTargetCues'] as bool? ?? true,
|
||||
blocks: [
|
||||
for (final block in json['blocks']! as List<Object?>)
|
||||
if (block
|
||||
case {
|
||||
'repeat': final int repeat,
|
||||
'steps': final List<Object?> steps,
|
||||
}
|
||||
when repeat > 0)
|
||||
PlanBlock(
|
||||
repeat: repeat,
|
||||
steps: [for (final step in steps) ?_stepOf(step)],
|
||||
),
|
||||
],
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('A plan not read: ${error.runtimeType}', name: 'Plan');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
PlanStep? _stepOf(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return null;
|
||||
}
|
||||
final kind = StepKind.values.asNameMap()[json['kind']];
|
||||
final end = switch (json['end']) {
|
||||
{'by': 'time', 'seconds': final int seconds} when seconds > 0 =>
|
||||
EndAfterTime(Duration(seconds: seconds)),
|
||||
{'by': 'distance', 'metres': final int metres} when metres > 0 =>
|
||||
EndAfterDistance(metres),
|
||||
{'by': 'lap'} => const EndWithLap(),
|
||||
_ => null,
|
||||
};
|
||||
if (kind == null || end == null) {
|
||||
return null;
|
||||
}
|
||||
return PlanStep(
|
||||
kind: kind,
|
||||
end: end,
|
||||
target: switch (json['target']) {
|
||||
{
|
||||
'kind': final String name,
|
||||
'low': final num low,
|
||||
'high': final num high,
|
||||
}
|
||||
when TargetKind.values.asNameMap()[name] != null =>
|
||||
Target(
|
||||
TargetKind.values.byName(name),
|
||||
low.toDouble(),
|
||||
high.toDouble(),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
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/plan/data/plan_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
|
||||
|
||||
/// The person's interval plans (ADR 0030, D42), sealed with their key for
|
||||
/// their row as workouts are (D7); a readable row is one the instance made
|
||||
/// up, and shows locked.
|
||||
class PlanRepository(final PlanGateway _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<StoredPlan>> all() async {
|
||||
await _keys.refresh();
|
||||
final rows = await _gateway.plans()
|
||||
..sort((a, b) => a.id.compareTo(b.id));
|
||||
return [
|
||||
for (final row in rows)
|
||||
StoredPlan(row.id, switch (_open(row.envelope, row.id)) {
|
||||
final payload? => decodePlan(payload),
|
||||
null => null,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/// Keeps a new plan and returns its id; throws [KeysMissing] where this
|
||||
/// device can't seal.
|
||||
Future<String> save(PlanContent 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, PlanContent 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, PlanContent content) async =>
|
||||
_envelope.seal(
|
||||
encodePlan(content),
|
||||
keyId: await _keys.keyToWrite(),
|
||||
table: 'plan',
|
||||
id: id,
|
||||
);
|
||||
|
||||
ByteData? _open(Envelope envelope, String id) {
|
||||
if (envelope.keyId == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_envelope.open(envelope, table: 'plan', id: id)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
const _warmUp = PlanStep(
|
||||
kind: StepKind.warmUp,
|
||||
end: EndAfterTime(Duration(minutes: 10)),
|
||||
);
|
||||
|
||||
/// The plans a new one may start from (ADR 0030, D42), named in the app's
|
||||
/// language: the 4×4 minutes in zone 4 with recoveries in zone 2, Tabata's
|
||||
/// eight rounds of 20 s hard and 10 s easy, and ten 400 m repeats.
|
||||
List<PlanContent> planTemplates(AppLocalizations l10n) => [
|
||||
PlanContent(
|
||||
name: l10n.planTemplateFourByFour,
|
||||
activity: Activity.running,
|
||||
blocks: [
|
||||
const PlanBlock(steps: [_warmUp]),
|
||||
PlanBlock(
|
||||
repeat: 4,
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: const EndAfterTime(Duration(minutes: 4)),
|
||||
target: Target.zone(4),
|
||||
),
|
||||
PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: const EndAfterTime(Duration(minutes: 3)),
|
||||
target: Target.zone(2),
|
||||
),
|
||||
],
|
||||
),
|
||||
const PlanBlock(
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.coolDown,
|
||||
end: EndAfterTime(Duration(minutes: 5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
PlanContent(
|
||||
name: l10n.planTemplateTabata,
|
||||
blocks: const [
|
||||
PlanBlock(steps: [_warmUp]),
|
||||
PlanBlock(
|
||||
repeat: 8,
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(seconds: 20)),
|
||||
),
|
||||
PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: EndAfterTime(Duration(seconds: 10)),
|
||||
),
|
||||
],
|
||||
),
|
||||
PlanBlock(
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.coolDown,
|
||||
end: EndAfterTime(Duration(minutes: 5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
PlanContent(
|
||||
name: l10n.planTemplateFourHundreds,
|
||||
activity: Activity.running,
|
||||
blocks: const [
|
||||
PlanBlock(steps: [_warmUp]),
|
||||
PlanBlock(
|
||||
repeat: 10,
|
||||
steps: [
|
||||
PlanStep(kind: StepKind.work, end: EndAfterDistance(400)),
|
||||
PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: EndAfterTime(Duration(seconds: 90)),
|
||||
),
|
||||
],
|
||||
),
|
||||
PlanBlock(
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.coolDown,
|
||||
end: EndAfterTime(Duration(minutes: 10)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
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/plan/data/plan_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [PlanGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
class SyncPlanGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements PlanGateway {
|
||||
@override
|
||||
Future<List<PlanRow>> plans() async => [
|
||||
for (final plan in await protocol.Plan.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(PlanRow plan) async {
|
||||
await protocol.Plan.db.insertRow(
|
||||
_db,
|
||||
protocol.Plan(
|
||||
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.Plan.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
)
|
||||
case final plan?) {
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.Plan.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.Plan.db.findById(_db, uuid, transaction: transaction)
|
||||
case final plan?) {
|
||||
await protocol.Plan.db.updateRow(
|
||||
_db,
|
||||
protocol.Plan(
|
||||
id: plan.id,
|
||||
spaceId: plan.spaceId,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.Plan.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(uuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// What a step of a plan is for (ADR 0030, D42).
|
||||
enum StepKind() {
|
||||
warmUp,
|
||||
work,
|
||||
recovery,
|
||||
coolDown,
|
||||
}
|
||||
|
||||
/// How a step ends: after a time, after a distance, or with a lap tap.
|
||||
sealed class const StepEnd() with Equatable;
|
||||
|
||||
final class const EndAfterTime(final Duration time) extends StepEnd {
|
||||
@override
|
||||
List<Object?> get props => [time];
|
||||
}
|
||||
|
||||
final class const EndAfterDistance(final int metres) extends StepEnd {
|
||||
@override
|
||||
List<Object?> get props => [metres];
|
||||
}
|
||||
|
||||
final class const EndWithLap() extends StepEnd {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// What a target measures, and in what: pace in seconds per km, speed in
|
||||
/// km/h, heart rate in bpm, a heart-rate zone (1 to 5), the rower's split in
|
||||
/// seconds per 500 m, its watts, its strokes a minute.
|
||||
enum TargetKind() {
|
||||
pace,
|
||||
speed,
|
||||
heartRate,
|
||||
heartZone,
|
||||
split,
|
||||
watts,
|
||||
strokeRate;
|
||||
|
||||
/// Whether a lower figure is the harder: a pace's, a split's.
|
||||
bool get lowerIsFaster => this == pace || this == split;
|
||||
}
|
||||
|
||||
/// A step's target: a range of [kind] from [low] to [high], both held;
|
||||
/// a zone's both its number.
|
||||
final class const Target(
|
||||
final TargetKind kind,
|
||||
final double low,
|
||||
final double high,
|
||||
) with Equatable {
|
||||
/// Heart-rate zone [zone], 1 to 5.
|
||||
new zone(int zone)
|
||||
: this(TargetKind.heartZone, zone.toDouble(), zone.toDouble());
|
||||
|
||||
@override
|
||||
List<Object?> get props => [kind, low, high];
|
||||
}
|
||||
|
||||
/// One step of a plan.
|
||||
final class const PlanStep({
|
||||
required final StepKind kind,
|
||||
required final StepEnd end,
|
||||
final Target? target,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [kind, end, target];
|
||||
}
|
||||
|
||||
/// Steps run [repeat] times in a row; a single step is a block of one.
|
||||
final class const PlanBlock({
|
||||
required final List<PlanStep> steps,
|
||||
final int repeat = 1,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [steps, repeat];
|
||||
}
|
||||
|
||||
/// A step as a recording runs it: the step, which round of its block, of how
|
||||
/// many.
|
||||
typedef RunStep = ({PlanStep step, int round, int rounds});
|
||||
|
||||
/// An interval plan (D42): its name, what it is for (null: any activity),
|
||||
/// its blocks, and whether a step off its target is told.
|
||||
final class const PlanContent({
|
||||
required final String name,
|
||||
required final List<PlanBlock> blocks,
|
||||
final Activity? activity,
|
||||
final bool offTargetCues = true,
|
||||
}) with Equatable {
|
||||
/// Every step in the order a recording runs them, each block's rounds
|
||||
/// spelled out.
|
||||
List<RunStep> get run => [
|
||||
for (final block in blocks)
|
||||
for (var round = 1; round <= block.repeat; round++)
|
||||
for (final step in block.steps)
|
||||
(step: step, round: round, rounds: block.repeat),
|
||||
];
|
||||
|
||||
/// How long its timed steps take together, and how far its measured
|
||||
/// steps go; the rest ends with a lap tap.
|
||||
Duration get timed => run.fold(
|
||||
Duration.zero,
|
||||
(sum, s) => s.step.end is EndAfterTime
|
||||
? sum + (s.step.end as EndAfterTime).time
|
||||
: sum,
|
||||
);
|
||||
|
||||
int get metres => run.fold(
|
||||
0,
|
||||
(sum, s) => switch (s.step.end) {
|
||||
EndAfterDistance(:final metres) => sum + metres,
|
||||
_ => sum,
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, blocks, activity, offTargetCues];
|
||||
}
|
||||
|
||||
/// The heart rates zone [zone] (1 to 5) spans for a highest rate of
|
||||
/// [maxHeartRate] (D42): Polar's and most watches', 50–60 % up to 90–100 %.
|
||||
({int low, int high}) heartZone(int zone, int maxHeartRate) => (
|
||||
low: (maxHeartRate * (40 + 10 * zone) / 100).round(),
|
||||
high: (maxHeartRate * (50 + 10 * zone) / 100).round(),
|
||||
);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
|
||||
/// A plan as the person's devices keep it: its id and what it is, or
|
||||
/// [content] null where it doesn't open here: shown as locked, never half.
|
||||
final class const StoredPlan(final String id, final PlanContent? content)
|
||||
with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, content];
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
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/plan/bloc/plan_editor_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/step_sheet.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens [plan] to be changed, or made where [id] is null.
|
||||
Future<void> openPlanScreen(
|
||||
BuildContext context,
|
||||
PlanContent plan, {
|
||||
String? id,
|
||||
}) {
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
final plans = context.read<PlansCubit>();
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: plans),
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
PlanEditorCubit(train.plans, plan: plan, id: id),
|
||||
),
|
||||
],
|
||||
child: const PlanScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// An interval plan being made or changed (ADR 0030, D42): its name, what it
|
||||
/// is for and whether a step off target is told; its steps, a repeat's
|
||||
/// under how often it runs; saved with the button, deleted after asking.
|
||||
class const PlanScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
PlanEditorCubit cubit() => context.read<PlanEditorCubit>();
|
||||
return BlocBuilder<PlanEditorCubit, PlanEditorState>(
|
||||
builder: (context, state) {
|
||||
final plan = state.plan;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(state.id == null ? l10n.planNewTitle : l10n.planTitle),
|
||||
actions: [
|
||||
if (state.id != null)
|
||||
IconButton(
|
||||
tooltip: l10n.planDelete,
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () => _delete(context),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: state.canSave ? () => _save(context) : null,
|
||||
child: Text(l10n.planSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextFormField(
|
||||
initialValue: plan.name,
|
||||
decoration: InputDecoration(labelText: l10n.planName),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (name) => cubit().rename(name),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: DropdownButtonFormField<Activity?>(
|
||||
initialValue: plan.activity,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.planActivity,
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(child: Text(l10n.planActivityAny)),
|
||||
for (final activity in recordedActivities)
|
||||
DropdownMenuItem(
|
||||
value: activity,
|
||||
child: Text(activityName(l10n, activity)),
|
||||
),
|
||||
],
|
||||
onChanged: (activity) => cubit().setActivity(activity),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: plan.offTargetCues,
|
||||
title: Text(l10n.planOffTarget),
|
||||
subtitle: Text(l10n.planOffTargetHint),
|
||||
onChanged: (on) => cubit().setOffTargetCues(on),
|
||||
),
|
||||
const Divider(),
|
||||
for (final (index, block) in plan.blocks.indexed)
|
||||
_Block(
|
||||
index,
|
||||
block,
|
||||
last: index == plan.blocks.length - 1,
|
||||
),
|
||||
if (plan.blocks.isNotEmpty)
|
||||
ListTile(
|
||||
title: Text(
|
||||
planSummary(l10n, locale, plan),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.planAddStep),
|
||||
onPressed: () => cubit().addStep(),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.repeat),
|
||||
label: Text(l10n.planAddRepeat),
|
||||
onPressed: () => cubit().addRepeat(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (await context.read<PlanEditorCubit>().save()) {
|
||||
navigator.pop();
|
||||
} else {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.planNotSaved)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final plans = context.read<PlansCubit>();
|
||||
final id = context.read<PlanEditorCubit>().state.id!;
|
||||
final navigator = Navigator.of(context);
|
||||
if (!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.planDeleteTitle,
|
||||
message: l10n.planDeleteMessage,
|
||||
confirmLabel: l10n.planDelete,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
plans.delete(id);
|
||||
navigator.pop();
|
||||
if (context.mounted) {
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.planDeleted,
|
||||
onUndo: () => plans.undo(id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A block of the plan: a step alone, or a repeat with how often it runs
|
||||
/// over its steps.
|
||||
class const _Block(
|
||||
final int _index,
|
||||
final PlanBlock _block, {
|
||||
required final bool last,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
PlanEditorCubit cubit() => context.read<PlanEditorCubit>();
|
||||
final menu = PopupMenuButton<String>(
|
||||
tooltip: l10n.planBlockMenu,
|
||||
onSelected: (action) => switch (action) {
|
||||
'up' => cubit().moveBlock(_index, up: true),
|
||||
'down' => cubit().moveBlock(_index, up: false),
|
||||
_ => cubit().removeBlock(_index),
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (_index > 0)
|
||||
PopupMenuItem(value: 'up', child: Text(l10n.planMoveUp)),
|
||||
if (!last) PopupMenuItem(value: 'down', child: Text(l10n.planMoveDown)),
|
||||
PopupMenuItem(value: 'remove', child: Text(l10n.planRemove)),
|
||||
],
|
||||
);
|
||||
if (_block.repeat == 1 && _block.steps.length == 1) {
|
||||
return _StepTile(_index, 0, _block.steps.single, trailing: menu);
|
||||
}
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.repeat),
|
||||
title: Text(l10n.planRepeat(_block.repeat)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.planFewer,
|
||||
icon: const Icon(LucideIcons.minus),
|
||||
onPressed: _block.repeat > 1
|
||||
? () => cubit().setRepeat(_index, _block.repeat - 1)
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.planMore,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
onPressed: () => cubit().setRepeat(_index, _block.repeat + 1),
|
||||
),
|
||||
menu,
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final (step, planStep) in _block.steps.indexed)
|
||||
_StepTile(
|
||||
_index,
|
||||
step,
|
||||
planStep,
|
||||
trailing: IconButton(
|
||||
tooltip: l10n.planRemove,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => cubit().removeStep(_index, step),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.planAddStep),
|
||||
onPressed: () => cubit().addStep(block: _index),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A step: its kind, its length and its target; tapped, it is changed.
|
||||
class const _StepTile(
|
||||
final int _block,
|
||||
final int _index,
|
||||
final PlanStep _step, {
|
||||
required final Widget trailing,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
PlanEditorCubit cubit() => context.read<PlanEditorCubit>();
|
||||
return ListTile(
|
||||
leading: Icon(switch (_step.kind) {
|
||||
StepKind.warmUp => LucideIcons.sunrise,
|
||||
StepKind.work => LucideIcons.zap,
|
||||
StepKind.recovery => LucideIcons.wind,
|
||||
StepKind.coolDown => LucideIcons.sunset,
|
||||
}),
|
||||
title: Text(stepKindName(l10n, _step.kind)),
|
||||
subtitle: Text(
|
||||
[
|
||||
stepLength(l10n, locale, _step.end),
|
||||
if (_step.target case final target?) targetText(l10n, target),
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: trailing,
|
||||
onTap: () async {
|
||||
final changed = await showStepSheet(context, _step);
|
||||
if (changed != null) {
|
||||
cubit().setStep(_block, _index, changed);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// A step's kind, as the app names it.
|
||||
String stepKindName(AppLocalizations l10n, StepKind kind) => switch (kind) {
|
||||
StepKind.warmUp => l10n.stepWarmUp,
|
||||
StepKind.work => l10n.stepWork,
|
||||
StepKind.recovery => l10n.stepRecovery,
|
||||
StepKind.coolDown => l10n.stepCoolDown,
|
||||
};
|
||||
|
||||
/// How long a step lasts: `4 min`, `1 min 30 s`, `20 s`, `400 m`, `1.2 km`,
|
||||
/// or until a lap tap.
|
||||
String stepLength(AppLocalizations l10n, String locale, StepEnd end) =>
|
||||
switch (end) {
|
||||
EndAfterTime(:final time) => clockLength(l10n, time),
|
||||
EndAfterDistance(:final metres) =>
|
||||
metres < 1000
|
||||
? l10n.stepMetres(metres)
|
||||
: l10n.stepKilometres(
|
||||
NumberFormat.decimalPatternDigits(
|
||||
locale: locale,
|
||||
decimalDigits: 1,
|
||||
).format(metres / 1000),
|
||||
),
|
||||
EndWithLap() => l10n.stepUntilLap,
|
||||
};
|
||||
|
||||
/// [time] in minutes and seconds, the zeros left out.
|
||||
String clockLength(AppLocalizations l10n, Duration time) {
|
||||
final minutes = time.inMinutes;
|
||||
final seconds = time.inSeconds % 60;
|
||||
return switch ((minutes, seconds)) {
|
||||
(0, _) => l10n.stepSeconds(seconds),
|
||||
(_, 0) => l10n.stepMinutes(minutes),
|
||||
_ => l10n.stepMinutesSeconds(minutes, seconds),
|
||||
};
|
||||
}
|
||||
|
||||
/// Seconds as `m:ss`, as a pace or a split reads.
|
||||
String minutesSeconds(double seconds) {
|
||||
final whole = seconds.round();
|
||||
return '${whole ~/ 60}:${(whole % 60).toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// `m:ss` (or whole seconds) as seconds; null for anything else.
|
||||
double? secondsOf(String text) {
|
||||
final parts = text.trim().split(':');
|
||||
final numbers = [for (final part in parts) int.tryParse(part)];
|
||||
return switch (numbers) {
|
||||
[final int seconds?] when seconds > 0 => seconds.toDouble(),
|
||||
[final int minutes?, final int seconds?]
|
||||
when seconds < 60 && minutes * 60 + seconds > 0 =>
|
||||
(minutes * 60 + seconds).toDouble(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// A target as the app shows it: `4:30–4:50 per km`, `Zone 4`, …
|
||||
String targetText(AppLocalizations l10n, Target target) {
|
||||
String whole(double value) => value.round().toString();
|
||||
return switch (target.kind) {
|
||||
TargetKind.pace => l10n.targetPace(
|
||||
minutesSeconds(target.low),
|
||||
minutesSeconds(target.high),
|
||||
),
|
||||
TargetKind.speed => l10n.targetSpeed(whole(target.low), whole(target.high)),
|
||||
TargetKind.heartRate => l10n.targetHeartRate(
|
||||
whole(target.low),
|
||||
whole(target.high),
|
||||
),
|
||||
TargetKind.heartZone => l10n.targetZone(target.low.round()),
|
||||
TargetKind.split => l10n.targetSplit(
|
||||
minutesSeconds(target.low),
|
||||
minutesSeconds(target.high),
|
||||
),
|
||||
TargetKind.watts => l10n.targetWatts(whole(target.low), whole(target.high)),
|
||||
TargetKind.strokeRate => l10n.targetStrokeRate(
|
||||
whole(target.low),
|
||||
whole(target.high),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// What a target measures, as the app names it.
|
||||
String targetKindName(AppLocalizations l10n, TargetKind kind) => switch (kind) {
|
||||
TargetKind.pace => l10n.targetKindPace,
|
||||
TargetKind.speed => l10n.targetKindSpeed,
|
||||
TargetKind.heartRate => l10n.targetKindHeartRate,
|
||||
TargetKind.heartZone => l10n.targetKindHeartZone,
|
||||
TargetKind.split => l10n.targetKindSplit,
|
||||
TargetKind.watts => l10n.targetKindWatts,
|
||||
TargetKind.strokeRate => l10n.targetKindStrokeRate,
|
||||
};
|
||||
|
||||
/// A plan's steps, time and distance in a line: `10 steps · 26 min · 1.6 km`.
|
||||
String planSummary(AppLocalizations l10n, String locale, PlanContent plan) => [
|
||||
l10n.planSteps(plan.run.length),
|
||||
if (plan.timed > Duration.zero) clockLength(l10n, plan.timed),
|
||||
if (plan.metres > 0) stepLength(l10n, locale, EndAfterDistance(plan.metres)),
|
||||
].join(' · ');
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_templates.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The person's interval plans as home lists them (ADR 0030, D42): by name,
|
||||
/// each with its steps, time and distance; a new one made from nothing or
|
||||
/// from a template.
|
||||
class const PlansList({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return BlocBuilder<PlansCubit, PlansState>(
|
||||
builder: (context, state) {
|
||||
if (!state.loaded) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
final plans = state.visible;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: [
|
||||
for (final row in [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.plus),
|
||||
title: Text(l10n.plansNew),
|
||||
onTap: () => _new(context),
|
||||
),
|
||||
if (plans.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48),
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.listOrdered,
|
||||
title: l10n.plansEmptyTitle,
|
||||
hint: l10n.plansEmptyHint,
|
||||
),
|
||||
),
|
||||
for (final plan in plans)
|
||||
switch (plan.content) {
|
||||
final content? => ListTile(
|
||||
leading: const Icon(LucideIcons.listOrdered),
|
||||
title: Text(content.name),
|
||||
subtitle: Text(planSummary(l10n, locale, content)),
|
||||
onTap: () => openPlanScreen(context, content, id: plan.id),
|
||||
),
|
||||
null => ListTile(
|
||||
leading: const Icon(LucideIcons.lock),
|
||||
title: Text(l10n.planLocked),
|
||||
),
|
||||
},
|
||||
])
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: row,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Asks what the new plan starts from, then opens it.
|
||||
Future<void> _new(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final start = await showAdaptiveSheet<PlanContent>(
|
||||
context,
|
||||
builder: (context) => SheetScaffold(
|
||||
title: l10n.plansNew,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.file),
|
||||
title: Text(l10n.plansNewEmpty),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).pop(const PlanContent(name: '', blocks: [])),
|
||||
),
|
||||
for (final template in planTemplates(l10n))
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.listOrdered),
|
||||
title: Text(template.name),
|
||||
subtitle: Text(planSummary(l10n, locale, template)),
|
||||
onTap: () => Navigator.of(context).pop(template),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (start != null && context.mounted) {
|
||||
await openPlanScreen(context, start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Changes [step] on a sheet (ADR 0030, D42): its kind, how it ends and its
|
||||
/// target; the step changed, or null where it was left.
|
||||
Future<PlanStep?> showStepSheet(BuildContext context, PlanStep step) =>
|
||||
showAdaptiveSheet<PlanStep>(
|
||||
context,
|
||||
builder: (context) => _StepSheet(step),
|
||||
);
|
||||
|
||||
/// How a step may end, as the sheet offers it.
|
||||
enum _EndBy() {
|
||||
time,
|
||||
distance,
|
||||
lap,
|
||||
}
|
||||
|
||||
class const _StepSheet(final PlanStep step) extends StatefulWidget {
|
||||
@override
|
||||
State<_StepSheet> createState() => _StepSheetState();
|
||||
}
|
||||
|
||||
class _StepSheetState() extends State<_StepSheet> {
|
||||
late var _kind = widget.step.kind;
|
||||
late var _endBy = switch (widget.step.end) {
|
||||
EndAfterTime() => _EndBy.time,
|
||||
EndAfterDistance() => _EndBy.distance,
|
||||
EndWithLap() => _EndBy.lap,
|
||||
};
|
||||
late final _time = TextEditingController(
|
||||
text: switch (widget.step.end) {
|
||||
EndAfterTime(:final time) => minutesSeconds(time.inSeconds.toDouble()),
|
||||
_ => '1:00',
|
||||
},
|
||||
);
|
||||
late final _metres = TextEditingController(
|
||||
text: switch (widget.step.end) {
|
||||
EndAfterDistance(:final metres) => '$metres',
|
||||
_ => '400',
|
||||
},
|
||||
);
|
||||
late TargetKind? _target = widget.step.target?.kind;
|
||||
late var _zone = widget.step.target?.kind == TargetKind.heartZone
|
||||
? widget.step.target!.low.round()
|
||||
: 3;
|
||||
late final _low = TextEditingController(
|
||||
text: _figure(widget.step.target?.low),
|
||||
);
|
||||
late final _high = TextEditingController(
|
||||
text: _figure(widget.step.target?.high),
|
||||
);
|
||||
|
||||
String _figure(double? value) => switch ((value, widget.step.target?.kind)) {
|
||||
(null, _) || (_, TargetKind.heartZone) => '',
|
||||
(final value?, TargetKind.pace || TargetKind.split) => minutesSeconds(
|
||||
value,
|
||||
),
|
||||
(final value?, _) => value.round().toString(),
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_time.dispose();
|
||||
_metres.dispose();
|
||||
_low.dispose();
|
||||
_high.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The step as the sheet has it; null while a figure doesn't read.
|
||||
PlanStep? get _step {
|
||||
final StepEnd end;
|
||||
switch (_endBy) {
|
||||
case _EndBy.time:
|
||||
final seconds = secondsOf(_time.text);
|
||||
if (seconds == null) {
|
||||
return null;
|
||||
}
|
||||
end = EndAfterTime(Duration(seconds: seconds.round()));
|
||||
case _EndBy.distance:
|
||||
final metres = int.tryParse(_metres.text.trim());
|
||||
if (metres == null || metres <= 0) {
|
||||
return null;
|
||||
}
|
||||
end = EndAfterDistance(metres);
|
||||
case _EndBy.lap:
|
||||
end = const EndWithLap();
|
||||
}
|
||||
final Target? target;
|
||||
switch (_target) {
|
||||
case null:
|
||||
target = null;
|
||||
case TargetKind.heartZone:
|
||||
target = Target.zone(_zone);
|
||||
case final kind:
|
||||
final clock = kind == TargetKind.pace || kind == TargetKind.split;
|
||||
double? read(String text) =>
|
||||
clock ? secondsOf(text) : double.tryParse(text.trim());
|
||||
final low = read(_low.text);
|
||||
final high = read(_high.text);
|
||||
if (low == null || high == null || low <= 0 || high <= 0) {
|
||||
return null;
|
||||
}
|
||||
target = Target(kind, low < high ? low : high, low < high ? high : low);
|
||||
}
|
||||
return PlanStep(kind: _kind, end: end, target: target);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final step = _step;
|
||||
final clock = _target == TargetKind.pace || _target == TargetKind.split;
|
||||
return SheetScaffold(
|
||||
title: l10n.stepTitle,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<StepKind>(
|
||||
initialValue: _kind,
|
||||
decoration: InputDecoration(labelText: l10n.stepKind),
|
||||
items: [
|
||||
for (final kind in StepKind.values)
|
||||
DropdownMenuItem(
|
||||
value: kind,
|
||||
child: Text(stepKindName(l10n, kind)),
|
||||
),
|
||||
],
|
||||
onChanged: (kind) => setState(() => _kind = kind!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<_EndBy>(
|
||||
initialValue: _endBy,
|
||||
decoration: InputDecoration(labelText: l10n.stepEnds),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: _EndBy.time,
|
||||
child: Text(l10n.stepEndTime),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: _EndBy.distance,
|
||||
child: Text(l10n.stepEndDistance),
|
||||
),
|
||||
DropdownMenuItem(value: _EndBy.lap, child: Text(l10n.stepEndLap)),
|
||||
],
|
||||
onChanged: (by) => setState(() => _endBy = by!),
|
||||
),
|
||||
if (_endBy != _EndBy.lap) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _endBy == _EndBy.time ? _time : _metres,
|
||||
decoration: InputDecoration(
|
||||
labelText: _endBy == _EndBy.time
|
||||
? l10n.stepTimeLabel
|
||||
: l10n.stepMetresLabel,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp('[0-9:]')),
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TargetKind?>(
|
||||
initialValue: _target,
|
||||
decoration: InputDecoration(labelText: l10n.stepTarget),
|
||||
items: [
|
||||
DropdownMenuItem(child: Text(l10n.targetNone)),
|
||||
for (final kind in TargetKind.values)
|
||||
DropdownMenuItem(
|
||||
value: kind,
|
||||
child: Text(targetKindName(l10n, kind)),
|
||||
),
|
||||
],
|
||||
onChanged: (kind) => setState(() {
|
||||
if (kind != _target) {
|
||||
_low.clear();
|
||||
_high.clear();
|
||||
}
|
||||
_target = kind;
|
||||
}),
|
||||
),
|
||||
if (_target == TargetKind.heartZone) ...[
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _zone,
|
||||
decoration: InputDecoration(labelText: l10n.stepZone),
|
||||
items: [
|
||||
for (var zone = 1; zone <= 5; zone++)
|
||||
DropdownMenuItem(
|
||||
value: zone,
|
||||
child: Text(l10n.targetZone(zone)),
|
||||
),
|
||||
],
|
||||
onChanged: (zone) => setState(() => _zone = zone!),
|
||||
),
|
||||
] else if (_target != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
for (final (controller, label) in [
|
||||
(_low, l10n.targetFrom),
|
||||
(_high, l10n.targetTo),
|
||||
]) ...[
|
||||
if (controller == _high) const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: clock ? '4:30' : null,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp('[0-9:.]')),
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
footer: FilledButton(
|
||||
onPressed: step == null ? null : () => Navigator.of(context).pop(step),
|
||||
child: Text(l10n.stepDone),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue