feat(train): equipment by place and a plate calculator (ADR 0034)
The places a person trains at (home, a gym), each a sealed, synced `training_place` row with its equipment: bars and handles with their own weight, plates by weight and count, dumbbells and kettlebells, the rest by count, the person's own by name. A new place starts from what a home or a gym usually has. The place last chosen is remembered on the device, never its location. The plate calculator loads each weight both sides alike, the heaviest load not above it with the fewest plates, heavier inside, and over a series of sets picks the loads making the fewest plate moves, listing what comes off and goes on. Equipment is in Settings; strength plans are to build on it. The migration only creates the table, its space index and its cascade to the sync engine's spaces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
b7b0b937fe
commit
064b911aa2
27 changed files with 24759 additions and 7 deletions
|
|
@ -580,5 +580,59 @@
|
|||
"bodyWeight": "{kg} kg",
|
||||
"bodyResting": "Ruhepuls {bpm}",
|
||||
"bodyMax": "Maximalpuls {bpm}",
|
||||
"homeToday": "Heute"
|
||||
"homeToday": "Heute",
|
||||
|
||||
"equipmentTitle": "Ausrüstung",
|
||||
"equipmentHint": "Wo du trainierst und was es dort gibt",
|
||||
"equipmentEmptyTitle": "Noch keine Orte",
|
||||
"equipmentEmptyHint": "Füge die Orte hinzu, an denen du trainierst, etwa dein Zuhause und dein Studio, mit dem, was es dort gibt. Beginne mit dem, was ein Zuhause oder ein Studio meist hat, oder mit nichts.",
|
||||
"equipmentAddPlace": "Ort hinzufügen",
|
||||
"equipmentItems": "{count, plural, =0{Noch nichts} =1{1 Ausrüstungsstück} other{{count} Ausrüstungsstücke}}",
|
||||
"equipmentChosen": "Hier trainierst du gerade",
|
||||
"equipmentChoose": "Hier trainieren",
|
||||
"equipmentLocked": "Lässt sich auf diesem Gerät nicht öffnen",
|
||||
"placeNewTitle": "Neuer Ort",
|
||||
"placeName": "Name",
|
||||
"placeStartFrom": "Beginnen mit",
|
||||
"placeStartHome": "Zuhause",
|
||||
"placeStartGym": "Studio",
|
||||
"placeStartNothing": "Nichts",
|
||||
"placeDefaultHome": "Zuhause",
|
||||
"placeDefaultGym": "Studio",
|
||||
"placeAdd": "Hinzufügen",
|
||||
"placeRename": "Umbenennen",
|
||||
"placeSave": "Speichern",
|
||||
"placeDelete": "Ort löschen",
|
||||
"placeDeleteMessage": "Der Ort und seine Ausrüstung werden auf all deinen Geräten gelöscht.",
|
||||
"placeAddEquipment": "Ausrüstung hinzufügen",
|
||||
"placeEmptyHint": "Hier ist noch nichts. Füge die Ausrüstung hinzu, die es an diesem Ort gibt.",
|
||||
"equipmentKind": "{kind, select, barbell{Langhantel} ezBar{SZ-Stange} trapBar{Trap Bar} dumbbellHandle{Kurzhantelgriffe} plates{Hantelscheiben} fixedDumbbells{Kurzhanteln} kettlebells{Kettlebells} bench{Bank} rack{Rack} pullUpBar{Klimmzugstange} dipBars{Dipstation} cableMachine{Kabelzug} bands{Widerstandsbänder} other{Eigenes}}",
|
||||
"itemName": "Was es ist",
|
||||
"itemHowMany": "Wie viele",
|
||||
"itemOwnWeight": "Eigengewicht",
|
||||
"itemWeights": "Gewichte",
|
||||
"itemAddWeight": "Gewicht hinzufügen",
|
||||
"itemDone": "Fertig",
|
||||
"itemRemove": "Entfernen",
|
||||
"itemWeightInvalid": "Ein Gewicht in Kilogramm, über 0",
|
||||
"weightKg": "{weight} kg",
|
||||
"weightCount": "{count} × {weight} kg",
|
||||
"plateCalculatorTitle": "Scheibenrechner",
|
||||
"plateCalculatorHint": "Was du auflegst und was sich zwischen den Sätzen ändert",
|
||||
"platePlace": "Ort",
|
||||
"plateBar": "Stange",
|
||||
"plateDumbbellPair": "Zwei Kurzhantelgriffe",
|
||||
"plateTargets": "Gewichte, kg",
|
||||
"plateTargetsHelper": "Eins für jeden Satz, etwa 60, 80, 100",
|
||||
"plateNoPlace": "Füge zuerst einen Ort hinzu, an dem du trainierst, mit seinen Scheiben.",
|
||||
"plateNoBar": "An diesem Ort gibt es nichts, worauf Scheiben passen. Füge eine Langhantel oder Kurzhantelgriffe hinzu.",
|
||||
"plateBelowBar": "Unter den {weight} kg der Stange",
|
||||
"plateShort": "{weight} kg unter deinem Ziel: Näher kommen die Scheiben nicht",
|
||||
"plateSide": "Jede Seite, von innen: {plates}",
|
||||
"plateBarAlone": "Nur die Stange",
|
||||
"plateOff": "Nimm ab: {plates}",
|
||||
"plateOn": "Leg auf: {plates}",
|
||||
"plateNoChange": "Nichts ändert sich",
|
||||
"plateMoves": "{count, plural, =0{Keine Scheibe bewegt sich} =1{1 Scheibe je Seite bewegt sich} other{{count} Scheiben je Seite bewegen sich}}",
|
||||
"plateSet": "Satz {number}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -734,5 +734,72 @@
|
|||
"@bodyResting": {"placeholders": {"bpm": {"type": "int"}}},
|
||||
"bodyMax": "max {bpm} bpm",
|
||||
"@bodyMax": {"placeholders": {"bpm": {"type": "int"}}},
|
||||
"homeToday": "Today"
|
||||
"homeToday": "Today",
|
||||
|
||||
"equipmentTitle": "Equipment",
|
||||
"@equipmentTitle": {"description": "Settings' entry and the screen of the places the person trains at, with what each has (ADR 0034)."},
|
||||
"equipmentHint": "Where you train, and what's there",
|
||||
"equipmentEmptyTitle": "No places yet",
|
||||
"equipmentEmptyHint": "Add the places you train at, such as your home and your gym, with what each has. Start from what a home or a gym usually has, or from nothing.",
|
||||
"equipmentAddPlace": "Add a place",
|
||||
"equipmentItems": "{count, plural, =0{Nothing yet} =1{1 piece of equipment} other{{count} pieces of equipment}}",
|
||||
"@equipmentItems": {"placeholders": {"count": {"type": "int"}}},
|
||||
"equipmentChosen": "Where you train now",
|
||||
"@equipmentChosen": {"description": "Marks the place chosen, which the plate calculator and plans use (ADR 0034, D4)."},
|
||||
"equipmentChoose": "Train here",
|
||||
"equipmentLocked": "Can't be opened on this device",
|
||||
"placeNewTitle": "New place",
|
||||
"placeName": "Name",
|
||||
"placeStartFrom": "Start from",
|
||||
"placeStartHome": "A home",
|
||||
"placeStartGym": "A gym",
|
||||
"placeStartNothing": "Nothing",
|
||||
"placeDefaultHome": "Home",
|
||||
"placeDefaultGym": "Gym",
|
||||
"placeAdd": "Add",
|
||||
"placeRename": "Rename",
|
||||
"placeSave": "Save",
|
||||
"placeDelete": "Delete place",
|
||||
"placeDeleteMessage": "The place and its equipment are deleted on all your devices.",
|
||||
"placeAddEquipment": "Add equipment",
|
||||
"placeEmptyHint": "Nothing here yet. Add the equipment this place has.",
|
||||
"equipmentKind": "{kind, select, barbell{Barbell} ezBar{EZ bar} trapBar{Trap bar} dumbbellHandle{Dumbbell handles} plates{Weight plates} fixedDumbbells{Dumbbells} kettlebells{Kettlebells} bench{Bench} rack{Rack} pullUpBar{Pull-up bar} dipBars{Dip bars} cableMachine{Cable machine} bands{Resistance bands} other{Your own}}",
|
||||
"@equipmentKind": {"placeholders": {"kind": {"type": "String"}}},
|
||||
"itemName": "What it is",
|
||||
"itemHowMany": "How many",
|
||||
"itemOwnWeight": "Its own weight",
|
||||
"itemWeights": "Weights",
|
||||
"itemAddWeight": "Add a weight",
|
||||
"itemDone": "Done",
|
||||
"itemRemove": "Remove",
|
||||
"itemWeightInvalid": "A weight in kilograms, above 0",
|
||||
"weightKg": "{weight} kg",
|
||||
"@weightKg": {"placeholders": {"weight": {"type": "String"}}},
|
||||
"weightCount": "{count} × {weight} kg",
|
||||
"@weightCount": {"placeholders": {"count": {"type": "int"}, "weight": {"type": "String"}}},
|
||||
"plateCalculatorTitle": "Plate calculator",
|
||||
"plateCalculatorHint": "What to load, and what changes between sets",
|
||||
"platePlace": "Place",
|
||||
"plateBar": "Bar",
|
||||
"plateDumbbellPair": "Two dumbbell handles",
|
||||
"plateTargets": "Weights, kg",
|
||||
"plateTargetsHelper": "One for each set, such as 60, 80, 100",
|
||||
"plateNoPlace": "Add a place you train at, with its plates, first.",
|
||||
"plateNoBar": "This place has nothing that takes plates. Add a barbell or dumbbell handles to it.",
|
||||
"plateBelowBar": "Below the bar's {weight} kg",
|
||||
"@plateBelowBar": {"placeholders": {"weight": {"type": "String"}}},
|
||||
"plateShort": "{weight} kg below what you asked for: the plates make no closer weight",
|
||||
"@plateShort": {"placeholders": {"weight": {"type": "String"}}},
|
||||
"plateSide": "Each side, from the inside: {plates}",
|
||||
"@plateSide": {"placeholders": {"plates": {"type": "String"}}},
|
||||
"plateBarAlone": "The bar alone",
|
||||
"plateOff": "Take off {plates}",
|
||||
"@plateOff": {"placeholders": {"plates": {"type": "String"}}},
|
||||
"plateOn": "Put on {plates}",
|
||||
"@plateOn": {"placeholders": {"plates": {"type": "String"}}},
|
||||
"plateNoChange": "Nothing changes",
|
||||
"plateMoves": "{count, plural, =0{No plate moves} =1{1 plate moves on each side} other{{count} plates move on each side}}",
|
||||
"@plateMoves": {"placeholders": {"count": {"type": "int"}}},
|
||||
"plateSet": "Set {number}",
|
||||
"@plateSet": {"placeholders": {"number": {"type": "int"}}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3507,6 +3507,324 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Today'**
|
||||
String get homeToday;
|
||||
|
||||
/// Settings' entry and the screen of the places the person trains at, with what each has (ADR 0034).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Equipment'**
|
||||
String get equipmentTitle;
|
||||
|
||||
/// No description provided for @equipmentHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Where you train, and what\'s there'**
|
||||
String get equipmentHint;
|
||||
|
||||
/// No description provided for @equipmentEmptyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No places yet'**
|
||||
String get equipmentEmptyTitle;
|
||||
|
||||
/// No description provided for @equipmentEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add the places you train at, such as your home and your gym, with what each has. Start from what a home or a gym usually has, or from nothing.'**
|
||||
String get equipmentEmptyHint;
|
||||
|
||||
/// No description provided for @equipmentAddPlace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a place'**
|
||||
String get equipmentAddPlace;
|
||||
|
||||
/// No description provided for @equipmentItems.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{Nothing yet} =1{1 piece of equipment} other{{count} pieces of equipment}}'**
|
||||
String equipmentItems(int count);
|
||||
|
||||
/// Marks the place chosen, which the plate calculator and plans use (ADR 0034, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Where you train now'**
|
||||
String get equipmentChosen;
|
||||
|
||||
/// No description provided for @equipmentChoose.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Train here'**
|
||||
String get equipmentChoose;
|
||||
|
||||
/// No description provided for @equipmentLocked.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Can\'t be opened on this device'**
|
||||
String get equipmentLocked;
|
||||
|
||||
/// No description provided for @placeNewTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New place'**
|
||||
String get placeNewTitle;
|
||||
|
||||
/// No description provided for @placeName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name'**
|
||||
String get placeName;
|
||||
|
||||
/// No description provided for @placeStartFrom.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start from'**
|
||||
String get placeStartFrom;
|
||||
|
||||
/// No description provided for @placeStartHome.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A home'**
|
||||
String get placeStartHome;
|
||||
|
||||
/// No description provided for @placeStartGym.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A gym'**
|
||||
String get placeStartGym;
|
||||
|
||||
/// No description provided for @placeStartNothing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing'**
|
||||
String get placeStartNothing;
|
||||
|
||||
/// No description provided for @placeDefaultHome.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Home'**
|
||||
String get placeDefaultHome;
|
||||
|
||||
/// No description provided for @placeDefaultGym.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Gym'**
|
||||
String get placeDefaultGym;
|
||||
|
||||
/// No description provided for @placeAdd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add'**
|
||||
String get placeAdd;
|
||||
|
||||
/// No description provided for @placeRename.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rename'**
|
||||
String get placeRename;
|
||||
|
||||
/// No description provided for @placeSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get placeSave;
|
||||
|
||||
/// No description provided for @placeDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete place'**
|
||||
String get placeDelete;
|
||||
|
||||
/// No description provided for @placeDeleteMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The place and its equipment are deleted on all your devices.'**
|
||||
String get placeDeleteMessage;
|
||||
|
||||
/// No description provided for @placeAddEquipment.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add equipment'**
|
||||
String get placeAddEquipment;
|
||||
|
||||
/// No description provided for @placeEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing here yet. Add the equipment this place has.'**
|
||||
String get placeEmptyHint;
|
||||
|
||||
/// No description provided for @equipmentKind.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{kind, select, barbell{Barbell} ezBar{EZ bar} trapBar{Trap bar} dumbbellHandle{Dumbbell handles} plates{Weight plates} fixedDumbbells{Dumbbells} kettlebells{Kettlebells} bench{Bench} rack{Rack} pullUpBar{Pull-up bar} dipBars{Dip bars} cableMachine{Cable machine} bands{Resistance bands} other{Your own}}'**
|
||||
String equipmentKind(String kind);
|
||||
|
||||
/// No description provided for @itemName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'What it is'**
|
||||
String get itemName;
|
||||
|
||||
/// No description provided for @itemHowMany.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How many'**
|
||||
String get itemHowMany;
|
||||
|
||||
/// No description provided for @itemOwnWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Its own weight'**
|
||||
String get itemOwnWeight;
|
||||
|
||||
/// No description provided for @itemWeights.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weights'**
|
||||
String get itemWeights;
|
||||
|
||||
/// No description provided for @itemAddWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a weight'**
|
||||
String get itemAddWeight;
|
||||
|
||||
/// No description provided for @itemDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get itemDone;
|
||||
|
||||
/// No description provided for @itemRemove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove'**
|
||||
String get itemRemove;
|
||||
|
||||
/// No description provided for @itemWeightInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A weight in kilograms, above 0'**
|
||||
String get itemWeightInvalid;
|
||||
|
||||
/// No description provided for @weightKg.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{weight} kg'**
|
||||
String weightKg(String weight);
|
||||
|
||||
/// No description provided for @weightCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} × {weight} kg'**
|
||||
String weightCount(int count, String weight);
|
||||
|
||||
/// No description provided for @plateCalculatorTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plate calculator'**
|
||||
String get plateCalculatorTitle;
|
||||
|
||||
/// No description provided for @plateCalculatorHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'What to load, and what changes between sets'**
|
||||
String get plateCalculatorHint;
|
||||
|
||||
/// No description provided for @platePlace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Place'**
|
||||
String get platePlace;
|
||||
|
||||
/// No description provided for @plateBar.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bar'**
|
||||
String get plateBar;
|
||||
|
||||
/// No description provided for @plateDumbbellPair.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Two dumbbell handles'**
|
||||
String get plateDumbbellPair;
|
||||
|
||||
/// No description provided for @plateTargets.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weights, kg'**
|
||||
String get plateTargets;
|
||||
|
||||
/// No description provided for @plateTargetsHelper.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'One for each set, such as 60, 80, 100'**
|
||||
String get plateTargetsHelper;
|
||||
|
||||
/// No description provided for @plateNoPlace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a place you train at, with its plates, first.'**
|
||||
String get plateNoPlace;
|
||||
|
||||
/// No description provided for @plateNoBar.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This place has nothing that takes plates. Add a barbell or dumbbell handles to it.'**
|
||||
String get plateNoBar;
|
||||
|
||||
/// No description provided for @plateBelowBar.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Below the bar\'s {weight} kg'**
|
||||
String plateBelowBar(String weight);
|
||||
|
||||
/// No description provided for @plateShort.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{weight} kg below what you asked for: the plates make no closer weight'**
|
||||
String plateShort(String weight);
|
||||
|
||||
/// No description provided for @plateSide.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Each side, from the inside: {plates}'**
|
||||
String plateSide(String plates);
|
||||
|
||||
/// No description provided for @plateBarAlone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The bar alone'**
|
||||
String get plateBarAlone;
|
||||
|
||||
/// No description provided for @plateOff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Take off {plates}'**
|
||||
String plateOff(String plates);
|
||||
|
||||
/// No description provided for @plateOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Put on {plates}'**
|
||||
String plateOn(String plates);
|
||||
|
||||
/// No description provided for @plateNoChange.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing changes'**
|
||||
String get plateNoChange;
|
||||
|
||||
/// No description provided for @plateMoves.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{No plate moves} =1{1 plate moves on each side} other{{count} plates move on each side}}'**
|
||||
String plateMoves(int count);
|
||||
|
||||
/// No description provided for @plateSet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set {number}'**
|
||||
String plateSet(int number);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -2112,4 +2112,224 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get homeToday => 'Heute';
|
||||
|
||||
@override
|
||||
String get equipmentTitle => 'Ausrüstung';
|
||||
|
||||
@override
|
||||
String get equipmentHint => 'Wo du trainierst und was es dort gibt';
|
||||
|
||||
@override
|
||||
String get equipmentEmptyTitle => 'Noch keine Orte';
|
||||
|
||||
@override
|
||||
String get equipmentEmptyHint =>
|
||||
'Füge die Orte hinzu, an denen du trainierst, etwa dein Zuhause und dein Studio, mit dem, was es dort gibt. Beginne mit dem, was ein Zuhause oder ein Studio meist hat, oder mit nichts.';
|
||||
|
||||
@override
|
||||
String get equipmentAddPlace => 'Ort hinzufügen';
|
||||
|
||||
@override
|
||||
String equipmentItems(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Ausrüstungsstücke',
|
||||
one: '1 Ausrüstungsstück',
|
||||
zero: 'Noch nichts',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get equipmentChosen => 'Hier trainierst du gerade';
|
||||
|
||||
@override
|
||||
String get equipmentChoose => 'Hier trainieren';
|
||||
|
||||
@override
|
||||
String get equipmentLocked => 'Lässt sich auf diesem Gerät nicht öffnen';
|
||||
|
||||
@override
|
||||
String get placeNewTitle => 'Neuer Ort';
|
||||
|
||||
@override
|
||||
String get placeName => 'Name';
|
||||
|
||||
@override
|
||||
String get placeStartFrom => 'Beginnen mit';
|
||||
|
||||
@override
|
||||
String get placeStartHome => 'Zuhause';
|
||||
|
||||
@override
|
||||
String get placeStartGym => 'Studio';
|
||||
|
||||
@override
|
||||
String get placeStartNothing => 'Nichts';
|
||||
|
||||
@override
|
||||
String get placeDefaultHome => 'Zuhause';
|
||||
|
||||
@override
|
||||
String get placeDefaultGym => 'Studio';
|
||||
|
||||
@override
|
||||
String get placeAdd => 'Hinzufügen';
|
||||
|
||||
@override
|
||||
String get placeRename => 'Umbenennen';
|
||||
|
||||
@override
|
||||
String get placeSave => 'Speichern';
|
||||
|
||||
@override
|
||||
String get placeDelete => 'Ort löschen';
|
||||
|
||||
@override
|
||||
String get placeDeleteMessage =>
|
||||
'Der Ort und seine Ausrüstung werden auf all deinen Geräten gelöscht.';
|
||||
|
||||
@override
|
||||
String get placeAddEquipment => 'Ausrüstung hinzufügen';
|
||||
|
||||
@override
|
||||
String get placeEmptyHint =>
|
||||
'Hier ist noch nichts. Füge die Ausrüstung hinzu, die es an diesem Ort gibt.';
|
||||
|
||||
@override
|
||||
String equipmentKind(String kind) {
|
||||
String _temp0 = intl.Intl.selectLogic(
|
||||
kind,
|
||||
{
|
||||
'barbell': 'Langhantel',
|
||||
'ezBar': 'SZ-Stange',
|
||||
'trapBar': 'Trap Bar',
|
||||
'dumbbellHandle': 'Kurzhantelgriffe',
|
||||
'plates': 'Hantelscheiben',
|
||||
'fixedDumbbells': 'Kurzhanteln',
|
||||
'kettlebells': 'Kettlebells',
|
||||
'bench': 'Bank',
|
||||
'rack': 'Rack',
|
||||
'pullUpBar': 'Klimmzugstange',
|
||||
'dipBars': 'Dipstation',
|
||||
'cableMachine': 'Kabelzug',
|
||||
'bands': 'Widerstandsbänder',
|
||||
'other': 'Eigenes',
|
||||
},
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemName => 'Was es ist';
|
||||
|
||||
@override
|
||||
String get itemHowMany => 'Wie viele';
|
||||
|
||||
@override
|
||||
String get itemOwnWeight => 'Eigengewicht';
|
||||
|
||||
@override
|
||||
String get itemWeights => 'Gewichte';
|
||||
|
||||
@override
|
||||
String get itemAddWeight => 'Gewicht hinzufügen';
|
||||
|
||||
@override
|
||||
String get itemDone => 'Fertig';
|
||||
|
||||
@override
|
||||
String get itemRemove => 'Entfernen';
|
||||
|
||||
@override
|
||||
String get itemWeightInvalid => 'Ein Gewicht in Kilogramm, über 0';
|
||||
|
||||
@override
|
||||
String weightKg(String weight) {
|
||||
return '$weight kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String weightCount(int count, String weight) {
|
||||
return '$count × $weight kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String get plateCalculatorTitle => 'Scheibenrechner';
|
||||
|
||||
@override
|
||||
String get plateCalculatorHint =>
|
||||
'Was du auflegst und was sich zwischen den Sätzen ändert';
|
||||
|
||||
@override
|
||||
String get platePlace => 'Ort';
|
||||
|
||||
@override
|
||||
String get plateBar => 'Stange';
|
||||
|
||||
@override
|
||||
String get plateDumbbellPair => 'Zwei Kurzhantelgriffe';
|
||||
|
||||
@override
|
||||
String get plateTargets => 'Gewichte, kg';
|
||||
|
||||
@override
|
||||
String get plateTargetsHelper => 'Eins für jeden Satz, etwa 60, 80, 100';
|
||||
|
||||
@override
|
||||
String get plateNoPlace =>
|
||||
'Füge zuerst einen Ort hinzu, an dem du trainierst, mit seinen Scheiben.';
|
||||
|
||||
@override
|
||||
String get plateNoBar =>
|
||||
'An diesem Ort gibt es nichts, worauf Scheiben passen. Füge eine Langhantel oder Kurzhantelgriffe hinzu.';
|
||||
|
||||
@override
|
||||
String plateBelowBar(String weight) {
|
||||
return 'Unter den $weight kg der Stange';
|
||||
}
|
||||
|
||||
@override
|
||||
String plateShort(String weight) {
|
||||
return '$weight kg unter deinem Ziel: Näher kommen die Scheiben nicht';
|
||||
}
|
||||
|
||||
@override
|
||||
String plateSide(String plates) {
|
||||
return 'Jede Seite, von innen: $plates';
|
||||
}
|
||||
|
||||
@override
|
||||
String get plateBarAlone => 'Nur die Stange';
|
||||
|
||||
@override
|
||||
String plateOff(String plates) {
|
||||
return 'Nimm ab: $plates';
|
||||
}
|
||||
|
||||
@override
|
||||
String plateOn(String plates) {
|
||||
return 'Leg auf: $plates';
|
||||
}
|
||||
|
||||
@override
|
||||
String get plateNoChange => 'Nichts ändert sich';
|
||||
|
||||
@override
|
||||
String plateMoves(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Scheiben je Seite bewegen sich',
|
||||
one: '1 Scheibe je Seite bewegt sich',
|
||||
zero: 'Keine Scheibe bewegt sich',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String plateSet(int number) {
|
||||
return 'Satz $number';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2100,6 +2100,226 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get homeToday => 'Today';
|
||||
|
||||
@override
|
||||
String get equipmentTitle => 'Equipment';
|
||||
|
||||
@override
|
||||
String get equipmentHint => 'Where you train, and what\'s there';
|
||||
|
||||
@override
|
||||
String get equipmentEmptyTitle => 'No places yet';
|
||||
|
||||
@override
|
||||
String get equipmentEmptyHint =>
|
||||
'Add the places you train at, such as your home and your gym, with what each has. Start from what a home or a gym usually has, or from nothing.';
|
||||
|
||||
@override
|
||||
String get equipmentAddPlace => 'Add a place';
|
||||
|
||||
@override
|
||||
String equipmentItems(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count pieces of equipment',
|
||||
one: '1 piece of equipment',
|
||||
zero: 'Nothing yet',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get equipmentChosen => 'Where you train now';
|
||||
|
||||
@override
|
||||
String get equipmentChoose => 'Train here';
|
||||
|
||||
@override
|
||||
String get equipmentLocked => 'Can\'t be opened on this device';
|
||||
|
||||
@override
|
||||
String get placeNewTitle => 'New place';
|
||||
|
||||
@override
|
||||
String get placeName => 'Name';
|
||||
|
||||
@override
|
||||
String get placeStartFrom => 'Start from';
|
||||
|
||||
@override
|
||||
String get placeStartHome => 'A home';
|
||||
|
||||
@override
|
||||
String get placeStartGym => 'A gym';
|
||||
|
||||
@override
|
||||
String get placeStartNothing => 'Nothing';
|
||||
|
||||
@override
|
||||
String get placeDefaultHome => 'Home';
|
||||
|
||||
@override
|
||||
String get placeDefaultGym => 'Gym';
|
||||
|
||||
@override
|
||||
String get placeAdd => 'Add';
|
||||
|
||||
@override
|
||||
String get placeRename => 'Rename';
|
||||
|
||||
@override
|
||||
String get placeSave => 'Save';
|
||||
|
||||
@override
|
||||
String get placeDelete => 'Delete place';
|
||||
|
||||
@override
|
||||
String get placeDeleteMessage =>
|
||||
'The place and its equipment are deleted on all your devices.';
|
||||
|
||||
@override
|
||||
String get placeAddEquipment => 'Add equipment';
|
||||
|
||||
@override
|
||||
String get placeEmptyHint =>
|
||||
'Nothing here yet. Add the equipment this place has.';
|
||||
|
||||
@override
|
||||
String equipmentKind(String kind) {
|
||||
String _temp0 = intl.Intl.selectLogic(
|
||||
kind,
|
||||
{
|
||||
'barbell': 'Barbell',
|
||||
'ezBar': 'EZ bar',
|
||||
'trapBar': 'Trap bar',
|
||||
'dumbbellHandle': 'Dumbbell handles',
|
||||
'plates': 'Weight plates',
|
||||
'fixedDumbbells': 'Dumbbells',
|
||||
'kettlebells': 'Kettlebells',
|
||||
'bench': 'Bench',
|
||||
'rack': 'Rack',
|
||||
'pullUpBar': 'Pull-up bar',
|
||||
'dipBars': 'Dip bars',
|
||||
'cableMachine': 'Cable machine',
|
||||
'bands': 'Resistance bands',
|
||||
'other': 'Your own',
|
||||
},
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemName => 'What it is';
|
||||
|
||||
@override
|
||||
String get itemHowMany => 'How many';
|
||||
|
||||
@override
|
||||
String get itemOwnWeight => 'Its own weight';
|
||||
|
||||
@override
|
||||
String get itemWeights => 'Weights';
|
||||
|
||||
@override
|
||||
String get itemAddWeight => 'Add a weight';
|
||||
|
||||
@override
|
||||
String get itemDone => 'Done';
|
||||
|
||||
@override
|
||||
String get itemRemove => 'Remove';
|
||||
|
||||
@override
|
||||
String get itemWeightInvalid => 'A weight in kilograms, above 0';
|
||||
|
||||
@override
|
||||
String weightKg(String weight) {
|
||||
return '$weight kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String weightCount(int count, String weight) {
|
||||
return '$count × $weight kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String get plateCalculatorTitle => 'Plate calculator';
|
||||
|
||||
@override
|
||||
String get plateCalculatorHint =>
|
||||
'What to load, and what changes between sets';
|
||||
|
||||
@override
|
||||
String get platePlace => 'Place';
|
||||
|
||||
@override
|
||||
String get plateBar => 'Bar';
|
||||
|
||||
@override
|
||||
String get plateDumbbellPair => 'Two dumbbell handles';
|
||||
|
||||
@override
|
||||
String get plateTargets => 'Weights, kg';
|
||||
|
||||
@override
|
||||
String get plateTargetsHelper => 'One for each set, such as 60, 80, 100';
|
||||
|
||||
@override
|
||||
String get plateNoPlace =>
|
||||
'Add a place you train at, with its plates, first.';
|
||||
|
||||
@override
|
||||
String get plateNoBar =>
|
||||
'This place has nothing that takes plates. Add a barbell or dumbbell handles to it.';
|
||||
|
||||
@override
|
||||
String plateBelowBar(String weight) {
|
||||
return 'Below the bar\'s $weight kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String plateShort(String weight) {
|
||||
return '$weight kg below what you asked for: the plates make no closer weight';
|
||||
}
|
||||
|
||||
@override
|
||||
String plateSide(String plates) {
|
||||
return 'Each side, from the inside: $plates';
|
||||
}
|
||||
|
||||
@override
|
||||
String get plateBarAlone => 'The bar alone';
|
||||
|
||||
@override
|
||||
String plateOff(String plates) {
|
||||
return 'Take off $plates';
|
||||
}
|
||||
|
||||
@override
|
||||
String plateOn(String plates) {
|
||||
return 'Put on $plates';
|
||||
}
|
||||
|
||||
@override
|
||||
String get plateNoChange => 'Nothing changes';
|
||||
|
||||
@override
|
||||
String plateMoves(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count plates move on each side',
|
||||
one: '1 plate moves on each side',
|
||||
zero: 'No plate moves',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String plateSet(int number) {
|
||||
return 'Set $number';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.
|
|||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/sync_day_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/data/chosen_place_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/data/place_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/data/sync_place_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/android_health_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
|
||||
|
|
@ -82,6 +85,13 @@ class TrainPerson(
|
|||
/// Their routes (D44), sealed and synced.
|
||||
required final RouteRepository routes,
|
||||
|
||||
/// The places they train at with their equipment (ADR 0034), sealed and
|
||||
/// synced.
|
||||
required final PlaceRepository places,
|
||||
|
||||
/// The place last chosen on this device (ADR 0034, D4).
|
||||
required final ChosenPlaceStore chosenPlace,
|
||||
|
||||
/// The instance's planning of routes (D44).
|
||||
required final RoutingGateway routing,
|
||||
|
||||
|
|
@ -403,6 +413,7 @@ Future<TrainPerson> _openPersonData(
|
|||
final days = DayRepository(SyncDayGateway(db, sync), keys);
|
||||
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
|
||||
final routes = RouteRepository(SyncRouteGateway(db, sync), keys);
|
||||
final places = PlaceRepository(SyncPlaceGateway(db, sync), keys);
|
||||
final maps = MapSourceRepository(
|
||||
ServerpodMapSourceGateway(client),
|
||||
kept: File(p.join(folder.path, 'map_source.json')),
|
||||
|
|
@ -460,6 +471,8 @@ Future<TrainPerson> _openPersonData(
|
|||
days: days,
|
||||
plans: plans,
|
||||
routes: routes,
|
||||
places: places,
|
||||
chosenPlace: ChosenPlaceStore(File(p.join(folder.path, 'place.json'))),
|
||||
routing: ServerpodRoutingGateway(client),
|
||||
placeSearch: ServerpodPlaceSearchGateway(client),
|
||||
maps: maps,
|
||||
|
|
@ -496,6 +509,7 @@ Future<TrainPerson> _openPersonData(
|
|||
await days.dispose();
|
||||
await plans.dispose();
|
||||
await routes.dispose();
|
||||
await places.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/data/chosen_place_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/data/place_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
|
||||
/// The places the person trains at (ADR 0034), by name, those this device
|
||||
/// can't open last, read again whenever they change, here or on another
|
||||
/// device; and the one chosen, remembered on this device (D4): the one last
|
||||
/// chosen while it is there, else the first.
|
||||
class PlacesCubit(
|
||||
final PlaceRepository _places,
|
||||
final ChosenPlaceStore _chosen,
|
||||
) extends Cubit<PlacesState> {
|
||||
this : super(const PlacesState());
|
||||
|
||||
StreamSubscription<void>? _changes;
|
||||
|
||||
Future<void> open() async {
|
||||
_changes = _places.changes.listen((_) => unawaited(_read()));
|
||||
await _read();
|
||||
}
|
||||
|
||||
/// Keeps a new place, chosen from then on, and returns its id.
|
||||
Future<String> add(PlaceContent content) async {
|
||||
final id = await _places.save(content);
|
||||
await choose(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> update(String id, PlaceContent content) =>
|
||||
_places.update(id, content);
|
||||
|
||||
Future<void> delete(String id) => _places.delete(id);
|
||||
|
||||
Future<void> choose(String id) async {
|
||||
await _chosen.write(id);
|
||||
if (!isClosed) {
|
||||
emit(state._with(chosen: id));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _read() async {
|
||||
final all = await _places.all();
|
||||
final opened =
|
||||
[
|
||||
for (final place in all)
|
||||
if (place.content != null) place,
|
||||
]..sort(
|
||||
(a, b) => a.content!.name.toLowerCase().compareTo(
|
||||
b.content!.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
state._with(
|
||||
all: [
|
||||
...opened,
|
||||
for (final place in all)
|
||||
if (place.content == null) place,
|
||||
],
|
||||
chosen: state._chosen ?? _chosen.read(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _changes?.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
||||
final class const PlacesState({
|
||||
final bool loaded = false,
|
||||
final List<StoredPlace> all = const [],
|
||||
final String? _chosen,
|
||||
}) with Equatable {
|
||||
/// The place chosen: the one last chosen while it opens here, else the
|
||||
/// first that does; null while none does.
|
||||
StoredPlace? get chosen {
|
||||
final open = [
|
||||
for (final place in all)
|
||||
if (place.content != null) place,
|
||||
];
|
||||
return open.where((place) => place.id == _chosen).firstOrNull ??
|
||||
open.firstOrNull;
|
||||
}
|
||||
|
||||
PlacesState _with({List<StoredPlace>? all, String? chosen}) => PlacesState(
|
||||
loaded: loaded || all != null,
|
||||
all: all ?? this.all,
|
||||
chosen: chosen ?? _chosen,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [loaded, all, _chosen];
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// The place last chosen on this device (ADR 0034, D4), in the person's
|
||||
/// `place.json`, never synced: where a phone is is the phone's. Without a
|
||||
/// file (widget tests), kept in memory.
|
||||
class ChosenPlaceStore([final File? _file]) {
|
||||
String? _memory;
|
||||
|
||||
String? read() {
|
||||
final file = _file;
|
||||
if (file == null) {
|
||||
return _memory;
|
||||
}
|
||||
try {
|
||||
return switch (jsonDecode(file.readAsStringSync())) {
|
||||
{'id': final String id} => id,
|
||||
_ => null,
|
||||
};
|
||||
} on Object {
|
||||
// None yet, or unreadable: as if none was chosen.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write(String id) async {
|
||||
final file = _file;
|
||||
if (file == null) {
|
||||
_memory = id;
|
||||
return;
|
||||
}
|
||||
await file.writeAsString(jsonEncode({'id': id}), flush: true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// A `training_place` row as kept: its id and its envelope, sealed (ADR 0030,
|
||||
/// D7).
|
||||
typedef PlaceRow = ({String id, Envelope envelope});
|
||||
|
||||
/// Where the person's training places are kept: their synced
|
||||
/// `training_place` rows (ADR 0034). The repository seals and opens;
|
||||
/// this only keeps.
|
||||
abstract interface class PlaceGateway() {
|
||||
Future<List<PlaceRow>> places();
|
||||
|
||||
Future<void> insert(PlaceRow place);
|
||||
|
||||
/// Replaces the place [id]'s envelope (an edit); nothing for one gone.
|
||||
Future<void> update(String id, Envelope envelope);
|
||||
|
||||
/// Deletes the place [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,83 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
|
||||
/// The schema of a place's payload this app writes and reads.
|
||||
const placeSchemaVersion = 1;
|
||||
|
||||
/// [content] as its row's payload, before sealing (ADR 0034): UTF-8 JSON,
|
||||
/// every weight in grams, what is counted by weight as `[grams, count]`
|
||||
/// pairs, the heaviest first.
|
||||
ByteData encodePlace(PlaceContent content) => ByteData.sublistView(
|
||||
Uint8List.fromList(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'schemaVersion': placeSchemaVersion,
|
||||
'name': content.name,
|
||||
'items': [
|
||||
for (final item in content.items)
|
||||
{
|
||||
'kind': item.kind.name,
|
||||
'name': ?item.name,
|
||||
'count': item.count,
|
||||
'grams': ?item.grams,
|
||||
if (item.weights.isNotEmpty)
|
||||
'weights': [
|
||||
for (final grams
|
||||
in item.weights.keys.toList()
|
||||
..sort((a, b) => b.compareTo(a)))
|
||||
[grams, item.weights[grams]],
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// The place [payload] holds, opened; null for one of a newer schema or
|
||||
/// one it can't read. An item of a kind it doesn't know is left out, as is
|
||||
/// a weight that isn't one.
|
||||
PlaceContent? decodePlace(ByteData payload) {
|
||||
try {
|
||||
final json = jsonDecode(
|
||||
utf8.decode(Uint8List.sublistView(payload)),
|
||||
) as Map<String, Object?>;
|
||||
final version = json['schemaVersion']! as int;
|
||||
if (version > placeSchemaVersion) {
|
||||
log('A place of schema $version skipped', name: 'Place');
|
||||
return null;
|
||||
}
|
||||
return PlaceContent(
|
||||
name: json['name']! as String,
|
||||
items: [
|
||||
for (final item in json['items']! as List<Object?>)
|
||||
if (item case final Map<String, Object?> fields
|
||||
when EquipmentKind.values.asNameMap()[fields['kind']] != null)
|
||||
EquipmentItem(
|
||||
kind: EquipmentKind.values.byName(fields['kind']! as String),
|
||||
name: fields['name'] as String?,
|
||||
count: switch (fields['count']) {
|
||||
final int count when count > 0 => count,
|
||||
_ => 1,
|
||||
},
|
||||
grams: switch (fields['grams']) {
|
||||
final int grams when grams > 0 => grams,
|
||||
_ => null,
|
||||
},
|
||||
weights: {
|
||||
for (final pair in fields['weights'] as List<Object?>? ?? [])
|
||||
if (pair case [final int grams, final int count]
|
||||
when grams > 0 && count > 0)
|
||||
grams: count,
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('A place not read: ${error.runtimeType}', name: 'Place');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
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/equipment/data/place_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/data/place_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
|
||||
/// The places the person trains at (ADR 0034), sealed with their key for
|
||||
/// their row as workouts are (D7); a readable row is one the instance made
|
||||
/// up, and shows locked.
|
||||
class PlaceRepository(final PlaceGateway _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 places changed, here or on the person's other devices.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// Every place, in the order they were made; one that doesn't open here
|
||||
/// with its content null.
|
||||
Future<List<StoredPlace>> all() async {
|
||||
await _keys.refresh();
|
||||
final rows = await _gateway.places()
|
||||
..sort((a, b) => a.id.compareTo(b.id));
|
||||
return [
|
||||
for (final row in rows)
|
||||
StoredPlace(row.id, switch (_open(row.envelope, row.id)) {
|
||||
final payload? => decodePlace(payload),
|
||||
null => null,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/// Keeps a new place and returns its id; throws [KeysMissing] where this
|
||||
/// device can't seal.
|
||||
Future<String> save(PlaceContent 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, PlaceContent 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, PlaceContent content) async =>
|
||||
_envelope.seal(
|
||||
encodePlace(content),
|
||||
keyId: await _keys.keyToWrite(),
|
||||
table: 'training_place',
|
||||
id: id,
|
||||
);
|
||||
|
||||
ByteData? _open(Envelope envelope, String id) {
|
||||
if (envelope.keyId == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_envelope.open(envelope, table: 'training_place', id: id)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/data/place_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [PlaceGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
class SyncPlaceGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements PlaceGateway {
|
||||
@override
|
||||
Future<List<PlaceRow>> places() async => [
|
||||
for (final place in await protocol.TrainingPlace.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
(
|
||||
id: place.id!.uuid,
|
||||
envelope: (
|
||||
keyId: place.keyId,
|
||||
nonce: place.nonce,
|
||||
payload: place.payload,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(PlaceRow place) async {
|
||||
await protocol.TrainingPlace.db.insertRow(
|
||||
_db,
|
||||
protocol.TrainingPlace(
|
||||
id: protocol.UuidValue.withValidation(place.id),
|
||||
keyId: place.envelope.keyId,
|
||||
nonce: place.envelope.nonce,
|
||||
payload: place.envelope.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (await protocol.TrainingPlace.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
)
|
||||
case final place?) {
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.TrainingPlace.db.updateRow(
|
||||
_db,
|
||||
place.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.TrainingPlace.db.findById(
|
||||
_db,
|
||||
uuid,
|
||||
transaction: transaction,
|
||||
)
|
||||
case final place?) {
|
||||
await protocol.TrainingPlace.db.updateRow(
|
||||
_db,
|
||||
protocol.TrainingPlace(
|
||||
id: place.id,
|
||||
spaceId: place.spaceId,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.TrainingPlace.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(uuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What Train knows of a piece of equipment (ADR 0034, D2), so that plans
|
||||
/// can rely on it; [own] is the person's, by name, which plans can name but
|
||||
/// not reason about.
|
||||
enum EquipmentKind() {
|
||||
/// A barbell, taking plates; its own weight given.
|
||||
barbell,
|
||||
|
||||
/// An EZ curl bar, taking plates.
|
||||
ezBar,
|
||||
|
||||
/// A trap (hex) bar, taking plates.
|
||||
trapBar,
|
||||
|
||||
/// A dumbbell handle, taking plates.
|
||||
dumbbellHandle,
|
||||
|
||||
/// Weight plates, by weight and count: one set a place.
|
||||
plates,
|
||||
|
||||
/// Dumbbells of a fixed weight, by weight and count.
|
||||
fixedDumbbells,
|
||||
|
||||
/// Kettlebells, by weight and count.
|
||||
kettlebells,
|
||||
|
||||
/// A bench.
|
||||
bench,
|
||||
|
||||
/// A squat or power rack.
|
||||
rack,
|
||||
|
||||
/// A pull-up bar.
|
||||
pullUpBar,
|
||||
|
||||
/// Dip bars.
|
||||
dipBars,
|
||||
|
||||
/// A cable machine.
|
||||
cableMachine,
|
||||
|
||||
/// Resistance bands.
|
||||
bands,
|
||||
|
||||
/// The person's own, by name.
|
||||
own;
|
||||
|
||||
/// Whether it takes plates, with a weight of its own.
|
||||
bool get loadable => switch (this) {
|
||||
barbell || ezBar || trapBar || dumbbellHandle => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Whether it is counted by weight: plates, fixed dumbbells, kettlebells.
|
||||
bool get byWeight => switch (this) {
|
||||
plates || fixedDumbbells || kettlebells => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// A new item of this kind, as a place usually has it: a barbell of
|
||||
/// 20 kg, a handle of 2 kg, an EZ bar of 10 kg, a trap bar of 25 kg.
|
||||
EquipmentItem get usual => EquipmentItem(
|
||||
kind: this,
|
||||
grams: switch (this) {
|
||||
barbell => 20000,
|
||||
ezBar => 10000,
|
||||
trapBar => 25000,
|
||||
dumbbellHandle => 2000,
|
||||
_ => null,
|
||||
},
|
||||
count: this == dumbbellHandle ? 2 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// A piece of equipment at a place: its [kind], its [name] (the person's
|
||||
/// own's), how many there are ([count]), a loadable one's own weight
|
||||
/// ([grams]), and what is counted by weight ([weights]: grams by count).
|
||||
final class const EquipmentItem({
|
||||
required final EquipmentKind kind,
|
||||
final String? name,
|
||||
final int count = 1,
|
||||
final int? grams,
|
||||
final Map<int, int> weights = const {},
|
||||
}) with Equatable {
|
||||
EquipmentItem copyWith({
|
||||
String? name,
|
||||
int? count,
|
||||
int? grams,
|
||||
Map<int, int>? weights,
|
||||
}) => EquipmentItem(
|
||||
kind: kind,
|
||||
name: name ?? this.name,
|
||||
count: count ?? this.count,
|
||||
grams: grams ?? this.grams,
|
||||
weights: weights ?? this.weights,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [kind, name, count, grams, weights];
|
||||
}
|
||||
|
||||
/// A place the person trains at (ADR 0034): its [name] and its [items].
|
||||
final class const PlaceContent({
|
||||
required final String name,
|
||||
final List<EquipmentItem> items = const [],
|
||||
}) with Equatable {
|
||||
/// What a home usually has: a barbell, two dumbbell handles, plates
|
||||
/// (two each of 20, 10 and 5 kg, four each of 2.5 and 1.25 kg), a
|
||||
/// bench and a pull-up bar.
|
||||
factory home(String name) => PlaceContent(
|
||||
name: name,
|
||||
items: [
|
||||
EquipmentKind.barbell.usual,
|
||||
EquipmentKind.dumbbellHandle.usual,
|
||||
const EquipmentItem(
|
||||
kind: EquipmentKind.plates,
|
||||
weights: {20000: 2, 10000: 2, 5000: 2, 2500: 4, 1250: 4},
|
||||
),
|
||||
EquipmentKind.bench.usual,
|
||||
EquipmentKind.pullUpBar.usual,
|
||||
],
|
||||
);
|
||||
|
||||
/// What a gym usually has: bars, plates of every common weight, fixed
|
||||
/// dumbbells from 2 to 30 kg, kettlebells, benches, racks, dip bars and
|
||||
/// cables.
|
||||
factory gym(String name) => PlaceContent(
|
||||
name: name,
|
||||
items: [
|
||||
EquipmentKind.barbell.usual.copyWith(count: 4),
|
||||
EquipmentKind.ezBar.usual,
|
||||
EquipmentKind.trapBar.usual,
|
||||
const EquipmentItem(
|
||||
kind: EquipmentKind.plates,
|
||||
weights: {
|
||||
25000: 8,
|
||||
20000: 8,
|
||||
15000: 8,
|
||||
10000: 8,
|
||||
5000: 8,
|
||||
2500: 8,
|
||||
1250: 8,
|
||||
},
|
||||
),
|
||||
EquipmentItem(
|
||||
kind: EquipmentKind.fixedDumbbells,
|
||||
weights: {
|
||||
for (var kg = 2; kg <= 10; kg += 2) kg * 1000: 2,
|
||||
for (var kg = 12; kg <= 30; kg += 2) kg * 1000: 2,
|
||||
},
|
||||
),
|
||||
const EquipmentItem(
|
||||
kind: EquipmentKind.kettlebells,
|
||||
weights: {8000: 1, 12000: 1, 16000: 1, 20000: 1, 24000: 1},
|
||||
),
|
||||
EquipmentKind.bench.usual.copyWith(count: 4),
|
||||
EquipmentKind.rack.usual.copyWith(count: 2),
|
||||
EquipmentKind.pullUpBar.usual,
|
||||
EquipmentKind.dipBars.usual,
|
||||
EquipmentKind.cableMachine.usual,
|
||||
],
|
||||
);
|
||||
|
||||
/// The plates, grams by count, all the place has.
|
||||
Map<int, int> get plates => {
|
||||
for (final item in items)
|
||||
if (item.kind == EquipmentKind.plates) ...item.weights,
|
||||
};
|
||||
|
||||
/// What takes plates.
|
||||
List<EquipmentItem> get loadable => [
|
||||
for (final item in items)
|
||||
if (item.kind.loadable) item,
|
||||
];
|
||||
|
||||
PlaceContent copyWith({String? name, List<EquipmentItem>? items}) =>
|
||||
PlaceContent(name: name ?? this.name, items: items ?? this.items);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, items];
|
||||
}
|
||||
|
||||
/// A place 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 StoredPlace(final String id, final PlaceContent? content)
|
||||
with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, content];
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
// Plate maths (ADR 0034, D3): what to load on a bar or a dumbbell handle for
|
||||
// a weight, from the plates a place has, and the fewest moves between the
|
||||
// loads of a series. Every weight is in grams, so sums are exact.
|
||||
|
||||
/// The plates on a bar or handle: [side], the plates of one side from the
|
||||
/// inside out, heaviest first, the same on the other; weighing [grams] with
|
||||
/// the bar, [short] of the target asked for.
|
||||
final class const PlateLoad({
|
||||
required final List<int> side,
|
||||
required final int grams,
|
||||
required final int short,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [side, grams, short];
|
||||
}
|
||||
|
||||
/// What changes between two loads, on each side: [off], the plates taken
|
||||
/// off from the outside in, then [on], those put on from the inside out.
|
||||
final class const PlateChange({
|
||||
required final List<int> off,
|
||||
required final List<int> on,
|
||||
}) with Equatable {
|
||||
/// How many plates move on a side.
|
||||
int get count => off.length + on.length;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [off, on];
|
||||
}
|
||||
|
||||
/// One set's load in a series: the [target] asked for, the [load] chosen
|
||||
/// (null for a target below the bar), and the [change] from the load
|
||||
/// before, the bar alone before the first (null where there is no load).
|
||||
final class const SeriesStep({
|
||||
required final int target,
|
||||
required final PlateLoad? load,
|
||||
required final PlateChange? change,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [target, load, change];
|
||||
}
|
||||
|
||||
/// The load for [target] on a bar or handle weighing [bar], from [plates]
|
||||
/// (grams by count, all the place has), on [implements] bars or handles at
|
||||
/// once (2 for a pair of dumbbells, each loaded as the target says): the
|
||||
/// heaviest not above the target, of those the one of fewest plates; null
|
||||
/// where the target is below the bar.
|
||||
PlateLoad? loadFor(
|
||||
int target, {
|
||||
required int bar,
|
||||
required Map<int, int> plates,
|
||||
int implements = 1,
|
||||
}) => switch (_Loads.of(target, bar, plates, implements)) {
|
||||
final loads? => loads.load(loads.sides.first),
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// The plates taken off and put on, a side, to go from the side [from] to
|
||||
/// [to]: the inner plates they share stay.
|
||||
PlateChange movesBetween(List<int> from, List<int> to) {
|
||||
var shared = 0;
|
||||
while (shared < from.length &&
|
||||
shared < to.length &&
|
||||
from[shared] == to[shared]) {
|
||||
shared++;
|
||||
}
|
||||
return PlateChange(
|
||||
off: from.sublist(shared).reversed.toList(),
|
||||
on: to.sublist(shared),
|
||||
);
|
||||
}
|
||||
|
||||
/// The loads for [targets] in their order, one after the other on the same
|
||||
/// bar or handles (as [loadFor]): of the loads weighing what each target
|
||||
/// gets, those making the fewest plate moves over the whole series, from
|
||||
/// the bar alone; of series moving as few, the one of fewer plates.
|
||||
List<SeriesStep> loadSeries(
|
||||
List<int> targets, {
|
||||
required int bar,
|
||||
required Map<int, int> plates,
|
||||
int implements = 1,
|
||||
}) {
|
||||
final options = [
|
||||
for (final target in targets) _Loads.of(target, bar, plates, implements),
|
||||
];
|
||||
// The fewest moves (then plates) reaching each option of each step, and
|
||||
// the option before it on that way: a shortest path through the options.
|
||||
final costs = <List<(int, int)>>[];
|
||||
final before = <List<int>>[];
|
||||
List<List<int>> sidesBefore = const [[]];
|
||||
var costsBefore = const [(0, 0)];
|
||||
for (final loads in options) {
|
||||
final sides = loads?.sides ?? const [<int>[]];
|
||||
final step = <(int, int)>[];
|
||||
final from = <int>[];
|
||||
for (final side in sides) {
|
||||
var best = (1 << 30, 0);
|
||||
var bestFrom = 0;
|
||||
for (final (index, earlier) in sidesBefore.indexed) {
|
||||
final (moves, plates) = costsBefore[index];
|
||||
final cost = (
|
||||
moves + movesBetween(earlier, side).count,
|
||||
plates + side.length,
|
||||
);
|
||||
if (cost.$1 < best.$1 || (cost.$1 == best.$1 && cost.$2 < best.$2)) {
|
||||
best = cost;
|
||||
bestFrom = index;
|
||||
}
|
||||
}
|
||||
step.add(best);
|
||||
from.add(bestFrom);
|
||||
}
|
||||
costs.add(step);
|
||||
before.add(from);
|
||||
sidesBefore = sides;
|
||||
costsBefore = step;
|
||||
}
|
||||
if (targets.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
// Back from the cheapest last option.
|
||||
var pick = 0;
|
||||
for (final (index, cost) in costs.last.indexed) {
|
||||
final best = costs.last[pick];
|
||||
if (cost.$1 < best.$1 || (cost.$1 == best.$1 && cost.$2 < best.$2)) {
|
||||
pick = index;
|
||||
}
|
||||
}
|
||||
final picks = List.filled(targets.length, 0);
|
||||
for (var step = targets.length - 1; step >= 0; step--) {
|
||||
picks[step] = pick;
|
||||
pick = before[step][pick];
|
||||
}
|
||||
final steps = <SeriesStep>[];
|
||||
var side = const <int>[];
|
||||
for (final (index, target) in targets.indexed) {
|
||||
final loads = options[index];
|
||||
if (loads == null) {
|
||||
steps.add(SeriesStep(target: target, load: null, change: null));
|
||||
side = const [];
|
||||
continue;
|
||||
}
|
||||
final next = loads.sides[picks[index]];
|
||||
steps.add(
|
||||
SeriesStep(
|
||||
target: target,
|
||||
load: loads.load(next),
|
||||
change: movesBetween(side, next),
|
||||
),
|
||||
);
|
||||
side = next;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/// The loads of the heaviest weight not above a target: every way the
|
||||
/// plates a side may take make it, fewest plates first.
|
||||
final class _Loads._(
|
||||
final int target,
|
||||
final int grams,
|
||||
final List<List<int>> sides,
|
||||
) {
|
||||
/// The loads for [target], or null where it is below [bar].
|
||||
static _Loads? of(
|
||||
int target,
|
||||
int bar,
|
||||
Map<int, int> plates,
|
||||
int implements,
|
||||
) {
|
||||
if (target < bar) {
|
||||
return null;
|
||||
}
|
||||
// Heaviest first; each as often as every side of every bar can have it.
|
||||
final weights = [
|
||||
for (final MapEntry(key: weight, value: count) in plates.entries)
|
||||
if (weight > 0 && count ~/ (2 * implements) > 0) weight,
|
||||
]..sort((a, b) => b.compareTo(a));
|
||||
final caps = [
|
||||
for (final weight in weights) plates[weight]! ~/ (2 * implements),
|
||||
];
|
||||
// Counted in the plates' common divisor, so the table stays small.
|
||||
final unit = weights.fold(
|
||||
0,
|
||||
(gcd, weight) => gcd == 0 ? weight : gcd.gcd(weight),
|
||||
);
|
||||
final budget = unit == 0 ? 0 : (target - bar) ~/ 2 ~/ unit;
|
||||
final sizes = [for (final weight in weights) weight ~/ unit];
|
||||
// reach[i][x]: whether the plates from the i-th on make x.
|
||||
final reach = List.generate(
|
||||
weights.length + 1,
|
||||
(_) => List.filled(budget + 1, false),
|
||||
);
|
||||
reach[weights.length][0] = true;
|
||||
for (var i = weights.length - 1; i >= 0; i--) {
|
||||
for (var x = 0; x <= budget; x++) {
|
||||
for (var k = 0; k <= caps[i] && k * sizes[i] <= x; k++) {
|
||||
if (reach[i + 1][x - k * sizes[i]]) {
|
||||
reach[i][x] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var best = budget;
|
||||
while (!reach[0][best]) {
|
||||
best--;
|
||||
}
|
||||
final sides = <List<int>>[];
|
||||
void collect(int i, int left, List<int> side) {
|
||||
if (sides.length >= _maxOptions) {
|
||||
return;
|
||||
}
|
||||
if (i == weights.length) {
|
||||
sides.add(side);
|
||||
return;
|
||||
}
|
||||
for (var k = math.min(caps[i], left ~/ sizes[i]); k >= 0; k--) {
|
||||
if (reach[i + 1][left - k * sizes[i]]) {
|
||||
collect(i + 1, left - k * sizes[i], [
|
||||
...side,
|
||||
for (var n = 0; n < k; n++) weights[i],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(0, best, const []);
|
||||
sides.sort((a, b) => a.length.compareTo(b.length));
|
||||
return _Loads._(target, bar + 2 * best * unit, sides);
|
||||
}
|
||||
|
||||
/// At most so many loads are weighed against each other for one target.
|
||||
static const _maxOptions = 64;
|
||||
|
||||
PlateLoad load(List<int> side) =>
|
||||
PlateLoad(side: side, grams: grams, short: target - grams);
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/bloc/places_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/widget/place_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/widget/plate_calculator_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens *Equipment* over [context]'s, from Settings.
|
||||
Future<void> openEquipmentScreen(BuildContext context) {
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) =>
|
||||
PlacesCubit(train.places, train.chosenPlace)..open(),
|
||||
child: const EquipmentScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushes [screen] over [context]'s, with its places.
|
||||
Future<void> _pushWithPlaces(BuildContext context, Widget screen) {
|
||||
final cubit = context.read<PlacesCubit>();
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => BlocProvider.value(value: cubit, child: screen),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The places the person trains at (ADR 0034): each with how much it has,
|
||||
/// the one chosen marked (D4), a place opening its equipment; a new one
|
||||
/// starting from what a home or a gym usually has, or from nothing (D6);
|
||||
/// the plate calculator in the app bar.
|
||||
class const EquipmentScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return BlocBuilder<PlacesCubit, PlacesState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.equipmentTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.plateCalculatorTitle,
|
||||
icon: const Icon(LucideIcons.calculator),
|
||||
onPressed: () => unawaited(
|
||||
_pushWithPlaces(context, const PlateCalculatorScreen()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
floatingActionButton: state.loaded
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => unawaited(_add(context)),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.equipmentAddPlace),
|
||||
)
|
||||
: null,
|
||||
body: !state.loaded
|
||||
? const AppLoadingIndicator()
|
||||
: state.all.isEmpty
|
||||
? Center(
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.dumbbell,
|
||||
title: l10n.equipmentEmptyTitle,
|
||||
hint: l10n.equipmentEmptyHint,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
12,
|
||||
12,
|
||||
12,
|
||||
// Room for the button below the last.
|
||||
96 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final place in state.all)
|
||||
switch (place.content) {
|
||||
final content? => ListTile(
|
||||
leading: Icon(
|
||||
place.id == state.chosen?.id
|
||||
? LucideIcons.mapPinCheck
|
||||
: LucideIcons.mapPin,
|
||||
color: place.id == state.chosen?.id
|
||||
? colors.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
content.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
place.id == state.chosen?.id
|
||||
? '${l10n.equipmentChosen} · '
|
||||
'${l10n.equipmentItems(content.items.length)}'
|
||||
: l10n.equipmentItems(
|
||||
content.items.length,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
),
|
||||
onTap: () => unawaited(
|
||||
_pushWithPlaces(
|
||||
context,
|
||||
PlaceScreen(id: place.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
null => ListTile(
|
||||
leading: const Icon(LucideIcons.lock),
|
||||
title: Text(l10n.equipmentLocked),
|
||||
trailing: IconButton(
|
||||
tooltip: l10n.placeDelete,
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () => unawaited(
|
||||
context.read<PlacesCubit>().delete(
|
||||
place.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _add(BuildContext context) async {
|
||||
final cubit = context.read<PlacesCubit>();
|
||||
final content = await showAdaptiveSheet<PlaceContent>(
|
||||
context,
|
||||
builder: (context) => const _NewPlaceSheet(),
|
||||
);
|
||||
if (content != null) {
|
||||
final id = await cubit.add(content);
|
||||
if (context.mounted) {
|
||||
await _pushWithPlaces(context, PlaceScreen(id: id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a new place starts from.
|
||||
enum _Start() {
|
||||
home,
|
||||
gym,
|
||||
nothing,
|
||||
}
|
||||
|
||||
/// A new place's name and what it starts from; the place, or null where
|
||||
/// the sheet was left.
|
||||
class const _NewPlaceSheet() extends StatefulWidget {
|
||||
@override
|
||||
State<_NewPlaceSheet> createState() => _NewPlaceSheetState();
|
||||
}
|
||||
|
||||
class _NewPlaceSheetState() extends State<_NewPlaceSheet> {
|
||||
var _start = _Start.home;
|
||||
final _name = TextEditingController();
|
||||
|
||||
/// Whether the name was typed, so a change of start leaves it.
|
||||
var _typed = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_typed) {
|
||||
_name.text = _defaultName(AppLocalizations.of(context));
|
||||
}
|
||||
}
|
||||
|
||||
String _defaultName(AppLocalizations l10n) => switch (_start) {
|
||||
_Start.home => l10n.placeDefaultHome,
|
||||
_Start.gym => l10n.placeDefaultGym,
|
||||
_Start.nothing => '',
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final name = _name.text.trim();
|
||||
return SheetScaffold(
|
||||
title: l10n.placeNewTitle,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.placeStartFrom,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<_Start>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: _Start.home,
|
||||
icon: const Icon(LucideIcons.house),
|
||||
label: Text(l10n.placeStartHome),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Start.gym,
|
||||
icon: const Icon(LucideIcons.dumbbell),
|
||||
label: Text(l10n.placeStartGym),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Start.nothing,
|
||||
label: Text(l10n.placeStartNothing),
|
||||
),
|
||||
],
|
||||
selected: {_start},
|
||||
onSelectionChanged: (chosen) => setState(() {
|
||||
_start = chosen.single;
|
||||
if (!_typed) {
|
||||
_name.text = _defaultName(l10n);
|
||||
}
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _name,
|
||||
maxLength: TextLimits.name,
|
||||
buildCounter: nearLimitCounter,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: requiredDecoration(
|
||||
context,
|
||||
InputDecoration(labelText: l10n.placeName),
|
||||
missing: name.isEmpty,
|
||||
),
|
||||
onChanged: (_) => setState(() => _typed = true),
|
||||
onSubmitted: (_) => _done(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
footer: FilledButton(
|
||||
onPressed: name.isEmpty ? null : _done,
|
||||
child: Text(l10n.placeAdd),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _done() {
|
||||
final name = _name.text.trim();
|
||||
if (name.isEmpty) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(switch (_start) {
|
||||
_Start.home => PlaceContent.home(name),
|
||||
_Start.gym => PlaceContent.gym(name),
|
||||
_Start.nothing => PlaceContent(name: name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// [grams] in kilograms as the person's language writes them, without
|
||||
/// trailing zeros: 20, 2.5, 1.25.
|
||||
String kilograms(int grams, String locale) =>
|
||||
NumberFormat('0.###', locale).format(grams / 1000);
|
||||
|
||||
/// The grams [text] says in kilograms, a comma or a point either way; null
|
||||
/// for what is no weight above 0.
|
||||
int? gramsOf(String text) {
|
||||
final value = double.tryParse(text.trim().replaceAll(',', '.'));
|
||||
if (value == null || !value.isFinite || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (value * 1000).round();
|
||||
}
|
||||
|
||||
/// The weights [text] lists, in kilograms, by spaces, semicolons or commas
|
||||
/// between them (a comma followed by a digit with no space is a decimal
|
||||
/// comma, as in 62,5); null where one isn't a weight.
|
||||
List<int>? gramsListOf(String text) {
|
||||
final parts = text
|
||||
.split(RegExp(r'[;\s]+|,(?=\s|$)'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList();
|
||||
final grams = [for (final part in parts) gramsOf(part)];
|
||||
return grams.contains(null) || grams.isEmpty
|
||||
? null
|
||||
: grams.whereType<int>().toList();
|
||||
}
|
||||
|
||||
extension EquipmentTexts on AppLocalizations {
|
||||
/// [item]'s name: its kind's, or the person's own.
|
||||
String itemTitle(EquipmentItem item) => switch (item.name) {
|
||||
final name? when item.kind == EquipmentKind.own && name.isNotEmpty => name,
|
||||
_ => equipmentKind(item.kind.name),
|
||||
};
|
||||
|
||||
/// [plates] from the inside out, those of one weight in a row together:
|
||||
/// "20 kg + 2 × 10 kg".
|
||||
String plateList(List<int> plates, String locale) {
|
||||
final runs = <(int, int)>[];
|
||||
for (final plate in plates) {
|
||||
if (runs.isNotEmpty && runs.last.$1 == plate) {
|
||||
runs.last = (plate, runs.last.$2 + 1);
|
||||
} else {
|
||||
runs.add((plate, 1));
|
||||
}
|
||||
}
|
||||
return [
|
||||
for (final (grams, count) in runs)
|
||||
if (count == 1)
|
||||
weightKg(kilograms(grams, locale))
|
||||
else
|
||||
weightCount(count, kilograms(grams, locale)),
|
||||
].join(' + ');
|
||||
}
|
||||
|
||||
/// What [item] holds, in a line: its own weight, how many, its weights.
|
||||
String itemSummary(EquipmentItem item, String locale) => [
|
||||
if (item.kind.byWeight)
|
||||
for (final grams
|
||||
in item.weights.keys.toList()..sort((a, b) => b.compareTo(a)))
|
||||
weightCount(item.weights[grams]!, kilograms(grams, locale))
|
||||
else ...[
|
||||
if (item.count > 1) '${item.count} ×',
|
||||
if (item.grams case final grams? when item.kind.loadable)
|
||||
weightKg(kilograms(grams, locale)),
|
||||
],
|
||||
].join(item.kind.byWeight ? ', ' : ' ');
|
||||
}
|
||||
|
||||
/// The icon of [kind]'s equipment.
|
||||
IconData equipmentIcon(EquipmentKind kind) => switch (kind) {
|
||||
EquipmentKind.barbell ||
|
||||
EquipmentKind.ezBar ||
|
||||
EquipmentKind.trapBar => LucideIcons.dumbbell,
|
||||
EquipmentKind.dumbbellHandle ||
|
||||
EquipmentKind.fixedDumbbells => LucideIcons.dumbbell,
|
||||
EquipmentKind.plates => LucideIcons.disc,
|
||||
EquipmentKind.kettlebells => LucideIcons.weight,
|
||||
EquipmentKind.bench => LucideIcons.armchair,
|
||||
EquipmentKind.rack => LucideIcons.columns3,
|
||||
EquipmentKind.pullUpBar || EquipmentKind.dipBars => LucideIcons.grip,
|
||||
EquipmentKind.cableMachine => LucideIcons.cable,
|
||||
EquipmentKind.bands => LucideIcons.infinity,
|
||||
EquipmentKind.own => LucideIcons.package,
|
||||
};
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/widget/equipment_texts.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What the item sheet ended with.
|
||||
sealed class ItemSheetResult();
|
||||
|
||||
/// The item as changed, or as it was where nothing changed.
|
||||
final class const ItemEdited(final EquipmentItem item)
|
||||
implements ItemSheetResult;
|
||||
|
||||
/// The item taken off the place.
|
||||
final class const ItemRemoved() implements ItemSheetResult;
|
||||
|
||||
/// Changes [item] on a sheet (ADR 0034, D2): the person's own's name; how
|
||||
/// many; a loadable one's own weight; the weights of what is counted by
|
||||
/// weight, each with how many. Null where it was left; [added] for an item
|
||||
/// not on the place yet, which has nothing to remove.
|
||||
Future<ItemSheetResult?> showItemSheet(
|
||||
BuildContext context,
|
||||
EquipmentItem item, {
|
||||
bool added = false,
|
||||
}) => showAdaptiveSheet<ItemSheetResult>(
|
||||
context,
|
||||
builder: (context) => _ItemSheet(item, added: added),
|
||||
);
|
||||
|
||||
class const _ItemSheet(final EquipmentItem item, {required final bool added})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_ItemSheet> createState() => _ItemSheetState();
|
||||
}
|
||||
|
||||
/// A weight being written: its kilograms and how many.
|
||||
final class _Weight(String kilograms, int count) {
|
||||
final field = TextEditingController(text: kilograms);
|
||||
int count = count;
|
||||
}
|
||||
|
||||
class _ItemSheetState() extends State<_ItemSheet> {
|
||||
late var _count = widget.item.count;
|
||||
late final _name = TextEditingController(text: widget.item.name ?? '');
|
||||
late final _grams = TextEditingController();
|
||||
final _weights = <_Weight>[];
|
||||
var _started = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
_started = true;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
if (widget.item.grams case final grams?) {
|
||||
_grams.text = kilograms(grams, locale);
|
||||
}
|
||||
final weights = widget.item.weights.keys.toList()
|
||||
..sort((a, b) => b.compareTo(a));
|
||||
_weights.addAll([
|
||||
for (final grams in weights)
|
||||
_Weight(kilograms(grams, locale), widget.item.weights[grams]!),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_grams.dispose();
|
||||
for (final weight in _weights) {
|
||||
weight.field.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
EquipmentKind get _kind => widget.item.kind;
|
||||
|
||||
/// The item as written; null while something isn't valid.
|
||||
EquipmentItem? get _item {
|
||||
final grams = _kind.loadable ? gramsOf(_grams.text) : null;
|
||||
if (_kind.loadable && grams == null) {
|
||||
return null;
|
||||
}
|
||||
final weights = <int, int>{};
|
||||
for (final weight in _weights) {
|
||||
final grams = gramsOf(weight.field.text);
|
||||
if (grams == null) {
|
||||
return null;
|
||||
}
|
||||
weights[grams] = (weights[grams] ?? 0) + weight.count;
|
||||
}
|
||||
final name = _name.text.trim();
|
||||
if (_kind == EquipmentKind.own && name.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return EquipmentItem(
|
||||
kind: _kind,
|
||||
name: _kind == EquipmentKind.own ? name : null,
|
||||
count: _kind.byWeight ? 1 : _count,
|
||||
grams: grams,
|
||||
weights: weights,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final item = _item;
|
||||
final kgFormatter = FilteringTextInputFormatter.allow(RegExp('[0-9.,]'));
|
||||
return SheetScaffold(
|
||||
title: l10n.itemTitle(widget.item),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_kind == EquipmentKind.own) ...[
|
||||
TextField(
|
||||
controller: _name,
|
||||
maxLength: TextLimits.name,
|
||||
buildCounter: nearLimitCounter,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: requiredDecoration(
|
||||
context,
|
||||
InputDecoration(labelText: l10n.itemName),
|
||||
missing: _name.text.trim().isEmpty,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (!_kind.byWeight)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(l10n.itemHowMany)),
|
||||
SizedBox(
|
||||
width: 156,
|
||||
child: CountStepper(
|
||||
value: '$_count',
|
||||
onFewer: _count > 1
|
||||
? () => setState(() => _count--)
|
||||
: null,
|
||||
onMore: () => setState(() => _count++),
|
||||
onTyped: (count) => setState(() => _count = count),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_kind.loadable) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _grams,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: [kgFormatter],
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.itemOwnWeight,
|
||||
suffixText: 'kg',
|
||||
errorText: gramsOf(_grams.text) == null
|
||||
? l10n.itemWeightInvalid
|
||||
: null,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
if (_kind.byWeight) ...[
|
||||
Text(
|
||||
l10n.itemWeights,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final (index, weight) in _weights.indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: weight.field,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: [kgFormatter],
|
||||
decoration: InputDecoration(
|
||||
suffixText: 'kg',
|
||||
isDense: true,
|
||||
errorText: gramsOf(weight.field.text) == null
|
||||
? l10n.itemWeightInvalid
|
||||
: null,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 156,
|
||||
child: CountStepper(
|
||||
value: '${weight.count}',
|
||||
suffix: ' ×',
|
||||
onFewer: weight.count > 1
|
||||
? () => setState(() => weight.count--)
|
||||
: null,
|
||||
onMore: () => setState(() => weight.count++),
|
||||
onTyped: (count) =>
|
||||
setState(() => weight.count = count),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.itemRemove,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => setState(() {
|
||||
_weights.removeAt(index).field.dispose();
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _weights.add(_Weight('', 2))),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.itemAddWeight),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
footer: Row(
|
||||
children: [
|
||||
if (!widget.added)
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(const ItemRemoved()),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
label: Text(l10n.itemRemove),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: item == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(ItemEdited(item)),
|
||||
child: Text(l10n.itemDone),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/bloc/places_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/widget/equipment_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/widget/item_sheet.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/widget/plate_calculator_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A place's equipment (ADR 0034, D2, D6): each piece with what it holds,
|
||||
/// a tap changing it; equipment added by kind; the place renamed, chosen
|
||||
/// to train at (D4) or deleted. Every change is kept as it is made.
|
||||
class const PlaceScreen({required final String id, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return BlocBuilder<PlacesCubit, PlacesState>(
|
||||
builder: (context, state) {
|
||||
final place = state.all.where((place) => place.id == id).firstOrNull;
|
||||
final content = place?.content;
|
||||
if (content == null) {
|
||||
// Gone, deleted here or on another device, or no longer opening.
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: state.loaded ? const SizedBox.shrink() : null,
|
||||
);
|
||||
}
|
||||
final chosen = state.chosen?.id == id;
|
||||
void save(List<EquipmentItem> items) => unawaited(
|
||||
context.read<PlacesCubit>().update(
|
||||
id,
|
||||
content.copyWith(items: items),
|
||||
),
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(content.name),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.plateCalculatorTitle,
|
||||
icon: const Icon(LucideIcons.calculator),
|
||||
onPressed: () => unawaited(_calculate(context)),
|
||||
),
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.pencil),
|
||||
onPressed: () => unawaited(_rename(context, content)),
|
||||
child: Text(l10n.placeRename),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () => unawaited(_delete(context)),
|
||||
child: Text(l10n.placeDelete),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, child) => IconButton(
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => unawaited(_addItem(context, content, save)),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.placeAddEquipment),
|
||||
),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
12,
|
||||
12,
|
||||
12,
|
||||
96 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: chosen
|
||||
? ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.mapPinCheck,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: Text(l10n.equipmentChosen),
|
||||
)
|
||||
: ListTile(
|
||||
leading: const Icon(LucideIcons.mapPin),
|
||||
title: Text(l10n.equipmentChoose),
|
||||
onTap: () => unawaited(
|
||||
context.read<PlacesCubit>().choose(id),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (content.items.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
l10n.placeEmptyHint,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (index, item) in content.items.indexed)
|
||||
ListTile(
|
||||
leading: Icon(equipmentIcon(item.kind)),
|
||||
title: Text(
|
||||
l10n.itemTitle(item),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: switch (l10n.itemSummary(
|
||||
item,
|
||||
locale,
|
||||
)) {
|
||||
'' => null,
|
||||
final summary => Text(summary),
|
||||
},
|
||||
onTap: () => unawaited(
|
||||
_editItem(context, content, index, save),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _calculate(BuildContext context) async {
|
||||
final cubit = context.read<PlacesCubit>();
|
||||
await cubit.choose(id);
|
||||
if (context.mounted) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: cubit,
|
||||
child: const PlateCalculatorScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addItem(
|
||||
BuildContext context,
|
||||
PlaceContent content,
|
||||
void Function(List<EquipmentItem>) save,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final kind = await showAdaptiveSheet<EquipmentKind>(
|
||||
context,
|
||||
builder: (context) => SheetScaffold(
|
||||
title: l10n.placeAddEquipment,
|
||||
body: Column(
|
||||
children: [
|
||||
for (final kind in EquipmentKind.values)
|
||||
ListTile(
|
||||
leading: Icon(equipmentIcon(kind)),
|
||||
title: Text(l10n.equipmentKind(kind.name)),
|
||||
onTap: () => Navigator.of(context).pop(kind),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (kind == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
final item = await showItemSheet(context, kind.usual, added: true);
|
||||
if (item case final ItemEdited edited) {
|
||||
save([...content.items, edited.item]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editItem(
|
||||
BuildContext context,
|
||||
PlaceContent content,
|
||||
int index,
|
||||
void Function(List<EquipmentItem>) save,
|
||||
) async {
|
||||
switch (await showItemSheet(context, content.items[index])) {
|
||||
case ItemEdited(:final item):
|
||||
save([...content.items]..[index] = item);
|
||||
case ItemRemoved():
|
||||
save([...content.items]..removeAt(index));
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rename(BuildContext context, PlaceContent content) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final cubit = context.read<PlacesCubit>();
|
||||
final controller = TextEditingController(text: content.name);
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.placeRename),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLength: TextLimits.name,
|
||||
buildCounter: nearLimitCounter,
|
||||
decoration: InputDecoration(labelText: l10n.placeName),
|
||||
onSubmitted: (name) => Navigator.of(context).pop(name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text),
|
||||
child: Text(l10n.placeSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
// Not disposed here: the dialog's field is still built while it fades
|
||||
// out, and a controller nothing else holds goes with it.
|
||||
if (name != null && name.trim().isNotEmpty) {
|
||||
await cubit.update(id, content.copyWith(name: name.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final cubit = context.read<PlacesCubit>();
|
||||
final navigator = Navigator.of(context);
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.placeDelete,
|
||||
message: l10n.placeDeleteMessage,
|
||||
confirmLabel: l10n.placeDelete,
|
||||
)) {
|
||||
navigator.pop();
|
||||
await cubit.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/bloc/places_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/plate_maths.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/widget/equipment_texts.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What takes the plates: a bar or handle of `grams`, `implements` at once
|
||||
/// (two for a pair of dumbbells).
|
||||
typedef _Bar = ({String label, int grams, int implements});
|
||||
|
||||
/// The plate calculator (ADR 0034, D3, D6): at the place chosen (D4), on a
|
||||
/// bar or handles it has, the load for each weight typed, the plates of a
|
||||
/// side from the inside out, how far below the weight it is where the
|
||||
/// plates make none closer, and between sets what comes off and goes on.
|
||||
class const PlateCalculatorScreen({super.key}) extends StatefulWidget {
|
||||
@override
|
||||
State<PlateCalculatorScreen> createState() => _PlateCalculatorScreenState();
|
||||
}
|
||||
|
||||
class _PlateCalculatorScreenState() extends State<PlateCalculatorScreen> {
|
||||
final _targets = TextEditingController();
|
||||
var _bar = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_targets.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<_Bar> _barsOf(
|
||||
PlaceContent place,
|
||||
AppLocalizations l10n,
|
||||
String locale,
|
||||
) => [
|
||||
for (final item in place.loadable)
|
||||
if (item.grams case final grams?) ...[
|
||||
(
|
||||
label:
|
||||
'${l10n.itemTitle(item)} · ${l10n.weightKg(kilograms(grams, locale))}',
|
||||
grams: grams,
|
||||
implements: 1,
|
||||
),
|
||||
if (item.kind == EquipmentKind.dumbbellHandle && item.count >= 2)
|
||||
(
|
||||
label:
|
||||
'${l10n.plateDumbbellPair} · ${l10n.weightKg(kilograms(grams, locale))}',
|
||||
grams: grams,
|
||||
implements: 2,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return BlocBuilder<PlacesCubit, PlacesState>(
|
||||
builder: (context, state) {
|
||||
final chosen = state.chosen;
|
||||
final place = chosen?.content;
|
||||
final bars = place == null
|
||||
? const <_Bar>[]
|
||||
: _barsOf(place, l10n, locale);
|
||||
final bar = bars.isEmpty ? null : bars[_bar.clamp(0, bars.length - 1)];
|
||||
final targets = gramsListOf(_targets.text);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.plateCalculatorTitle)),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: !state.loaded
|
||||
? const AppLoadingIndicator()
|
||||
: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
if (place == null)
|
||||
Text(l10n.plateNoPlace, textAlign: TextAlign.center)
|
||||
else ...[
|
||||
DropdownMenu<String>(
|
||||
expandedInsets: EdgeInsets.zero,
|
||||
label: Text(l10n.platePlace),
|
||||
initialSelection: chosen!.id,
|
||||
dropdownMenuEntries: [
|
||||
for (final each in state.all)
|
||||
if (each.content case final content?)
|
||||
DropdownMenuEntry(
|
||||
value: each.id,
|
||||
label: content.name,
|
||||
),
|
||||
],
|
||||
onSelected: (id) {
|
||||
if (id != null) {
|
||||
setState(() => _bar = 0);
|
||||
unawaited(
|
||||
context.read<PlacesCubit>().choose(id),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (bar == null)
|
||||
Text(l10n.plateNoBar, textAlign: TextAlign.center)
|
||||
else ...[
|
||||
DropdownMenu<int>(
|
||||
// A new place's bars are another list.
|
||||
key: ValueKey(chosen.id),
|
||||
expandedInsets: EdgeInsets.zero,
|
||||
label: Text(l10n.plateBar),
|
||||
initialSelection: bars.indexOf(bar),
|
||||
dropdownMenuEntries: [
|
||||
for (final (index, each) in bars.indexed)
|
||||
DropdownMenuEntry(
|
||||
value: index,
|
||||
label: each.label,
|
||||
),
|
||||
],
|
||||
onSelected: (index) =>
|
||||
setState(() => _bar = index ?? 0),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _targets,
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.plateTargets,
|
||||
helperText: l10n.plateTargetsHelper,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (targets != null)
|
||||
..._results(
|
||||
context,
|
||||
loadSeries(
|
||||
targets,
|
||||
bar: bar.grams,
|
||||
plates: place.plates,
|
||||
implements: bar.implements,
|
||||
),
|
||||
bar,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _results(
|
||||
BuildContext context,
|
||||
List<SeriesStep> steps,
|
||||
_Bar bar,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
return [
|
||||
for (final (index, step) in steps.indexed) ...[
|
||||
if (index > 0) const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (steps.length > 1)
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.plateSet(index + 1),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.weightKg(
|
||||
kilograms(step.load?.grams ?? step.target, locale),
|
||||
),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
switch (step.load) {
|
||||
null => Text(
|
||||
l10n.plateBelowBar(kilograms(bar.grams, locale)),
|
||||
style: muted,
|
||||
),
|
||||
final load => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
load.side.isEmpty
|
||||
? l10n.plateBarAlone
|
||||
: l10n.plateSide(l10n.plateList(load.side, locale)),
|
||||
),
|
||||
if (load.short > 0)
|
||||
Text(
|
||||
l10n.plateShort(kilograms(load.short, locale)),
|
||||
style: muted?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
if (step.change case final change? when steps.length > 1) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (change.count == 0)
|
||||
Text(l10n.plateNoChange, style: muted)
|
||||
else ...[
|
||||
if (change.off.isNotEmpty)
|
||||
Text(
|
||||
l10n.plateOff(l10n.plateList(change.off, locale)),
|
||||
style: muted,
|
||||
),
|
||||
if (change.on.isNotEmpty)
|
||||
Text(
|
||||
l10n.plateOn(l10n.plateList(change.on, locale)),
|
||||
style: muted,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (steps.length > 1) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.plateMoves(
|
||||
steps.fold(0, (sum, step) => sum + (step.change?.count ?? 0)),
|
||||
),
|
||||
style: muted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ 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/app/train_frame.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_paths.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/widget/equipment_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/widget/loop_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
|
||||
|
|
@ -78,6 +79,14 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
subtitle: Text(l10n.profileHint),
|
||||
onTap: () => openProfileScreen(context),
|
||||
),
|
||||
// The places trained at and their equipment, with
|
||||
// the plate calculator (ADR 0034, D6).
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.dumbbell),
|
||||
title: Text(l10n.equipmentTitle),
|
||||
subtitle: Text(l10n.equipmentHint),
|
||||
onTap: () => openEquipmentScreen(context),
|
||||
),
|
||||
// Health Connect on this phone (D31).
|
||||
if (train.health != null)
|
||||
ListTile(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue