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:
Tizian 「ティツィアーン」 2026-11-02 11:59:24 +01:00
commit 064b911aa2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
27 changed files with 24759 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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`).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,99 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/feature/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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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