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:
Tizian 「ティツィアーン」 2026-10-28 22:35:22 +01:00
commit f406ddcc59
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
27 changed files with 23608 additions and 10 deletions

View file

@ -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}}",

View file

@ -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}}",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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!),
),

View file

@ -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 {

View file

@ -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];
}

View file

@ -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];
}

View file

@ -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;
}

View file

@ -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,
},
);
}

View file

@ -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,
};
}
}

View file

@ -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)),
),
],
),
],
),
];

View file

@ -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;
}

View file

@ -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(),
);

View file

@ -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];
}

View file

@ -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);
}
},
);
}
}

View file

@ -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(' · ');

View file

@ -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);
}
}
}

View file

@ -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