feat(train): a weight vest among the equipment (ADR 0034, D7)
All checks were successful
CI / check (push) Successful in 45m21s
CI / deploy (push) Successful in 1m1s

A vest keeps its weight and, if adjustable, how far it goes. At a place
that has one, a set of bodyweight work offers it beside the weight, a
tap writing its weight in.

Whats-New: Add a weight vest to your equipment, and put its weight on a bodyweight set with a tap.
Whats-New-De: Trage eine Gewichtsweste bei deiner Ausrüstung ein und übernimm ihr Gewicht mit einem Tipp in einen Satz mit Körpergewicht.

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-05 16:47:23 +01:00
commit 011b144ba1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 332 additions and 15 deletions

View file

@ -617,7 +617,7 @@
"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}}",
"equipmentKind": "{kind, select, barbell{Langhantel} ezBar{SZ-Stange} trapBar{Trap Bar} dumbbellHandle{Kurzhantelgriffe} plates{Hantelscheiben} fixedDumbbells{Kurzhanteln} kettlebells{Kettlebells} weightVest{Gewichtsweste} bench{Bank} rack{Rack} pullUpBar{Klimmzugstange} dipBars{Dipstation} cableMachine{Kabelzug} bands{Widerstandsbänder} other{Eigenes}}",
"itemName": "Was es ist",
"itemHowMany": "Wie viele",
"itemOwnWeight": "Eigengewicht",
@ -626,6 +626,12 @@
"itemDone": "Fertig",
"itemRemove": "Entfernen",
"itemWeightInvalid": "Ein Gewicht in Kilogramm, über 0",
"weightRangeKg": "{from}–{to} kg",
"itemVestWeight": "Gewicht, oder das geringste",
"itemVestUpTo": "Bis",
"itemVestUpToHint": "Nur, wenn sie verstellbar ist",
"itemVestUpToInvalid": "Mehr als das geringste Gewicht",
"strengthWearVest": "{name}, {weight}",
"weightKg": "{weight} kg",
"weightCount": "{count} × {weight} kg",
"plateCalculatorTitle": "Scheibenrechner",

View file

@ -1702,7 +1702,7 @@
"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": "{kind, select, barbell{Barbell} ezBar{EZ bar} trapBar{Trap bar} dumbbellHandle{Dumbbell handles} plates{Weight plates} fixedDumbbells{Dumbbells} kettlebells{Kettlebells} weightVest{Weight vest} bench{Bench} rack{Rack} pullUpBar{Pull-up bar} dipBars{Dip bars} cableMachine{Cable machine} bands{Resistance bands} other{Your own}}",
"@equipmentKind": {
"placeholders": {
"kind": {
@ -1718,6 +1718,19 @@
"itemDone": "Done",
"itemRemove": "Remove",
"itemWeightInvalid": "A weight in kilograms, above 0",
"weightRangeKg": "{from}–{to} kg",
"@weightRangeKg": {
"placeholders": { "from": { "type": "String" }, "to": { "type": "String" } }
},
"itemVestWeight": "Weight, or its lightest",
"itemVestUpTo": "Up to",
"itemVestUpToHint": "Only if it's adjustable",
"itemVestUpToInvalid": "More than its lightest weight",
"strengthWearVest": "{name}, {weight}",
"@strengthWearVest": {
"description": "A chip at a set of bodyweight work offering the place's weight vest: its name, and its weight or range.",
"placeholders": { "name": { "type": "String" }, "weight": { "type": "String" } }
},
"weightKg": "{weight} kg",
"@weightKg": {
"placeholders": {

View file

@ -3805,7 +3805,7 @@ abstract class AppLocalizations {
/// 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}}'**
/// **'{kind, select, barbell{Barbell} ezBar{EZ bar} trapBar{Trap bar} dumbbellHandle{Dumbbell handles} plates{Weight plates} fixedDumbbells{Dumbbells} kettlebells{Kettlebells} weightVest{Weight vest} 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.
@ -3856,6 +3856,42 @@ abstract class AppLocalizations {
/// **'A weight in kilograms, above 0'**
String get itemWeightInvalid;
/// No description provided for @weightRangeKg.
///
/// In en, this message translates to:
/// **'{from}–{to} kg'**
String weightRangeKg(String from, String to);
/// No description provided for @itemVestWeight.
///
/// In en, this message translates to:
/// **'Weight, or its lightest'**
String get itemVestWeight;
/// No description provided for @itemVestUpTo.
///
/// In en, this message translates to:
/// **'Up to'**
String get itemVestUpTo;
/// No description provided for @itemVestUpToHint.
///
/// In en, this message translates to:
/// **'Only if it\'s adjustable'**
String get itemVestUpToHint;
/// No description provided for @itemVestUpToInvalid.
///
/// In en, this message translates to:
/// **'More than its lightest weight'**
String get itemVestUpToInvalid;
/// A chip at a set of bodyweight work offering the place's weight vest: its name, and its weight or range.
///
/// In en, this message translates to:
/// **'{name}, {weight}'**
String strengthWearVest(String name, String weight);
/// No description provided for @weightKg.
///
/// In en, this message translates to:

View file

@ -2298,6 +2298,7 @@ class AppLocalizationsDe extends AppLocalizations {
'plates': 'Hantelscheiben',
'fixedDumbbells': 'Kurzhanteln',
'kettlebells': 'Kettlebells',
'weightVest': 'Gewichtsweste',
'bench': 'Bank',
'rack': 'Rack',
'pullUpBar': 'Klimmzugstange',
@ -2334,6 +2335,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get itemWeightInvalid => 'Ein Gewicht in Kilogramm, über 0';
@override
String weightRangeKg(String from, String to) {
return '$from–$to kg';
}
@override
String get itemVestWeight => 'Gewicht, oder das geringste';
@override
String get itemVestUpTo => 'Bis';
@override
String get itemVestUpToHint => 'Nur, wenn sie verstellbar ist';
@override
String get itemVestUpToInvalid => 'Mehr als das geringste Gewicht';
@override
String strengthWearVest(String name, String weight) {
return '$name, $weight';
}
@override
String weightKg(String weight) {
return '$weight kg';

View file

@ -2286,6 +2286,7 @@ class AppLocalizationsEn extends AppLocalizations {
'plates': 'Weight plates',
'fixedDumbbells': 'Dumbbells',
'kettlebells': 'Kettlebells',
'weightVest': 'Weight vest',
'bench': 'Bench',
'rack': 'Rack',
'pullUpBar': 'Pull-up bar',
@ -2322,6 +2323,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get itemWeightInvalid => 'A weight in kilograms, above 0';
@override
String weightRangeKg(String from, String to) {
return '$from–$to kg';
}
@override
String get itemVestWeight => 'Weight, or its lightest';
@override
String get itemVestUpTo => 'Up to';
@override
String get itemVestUpToHint => 'Only if it\'s adjustable';
@override
String get itemVestUpToInvalid => 'More than its lightest weight';
@override
String strengthWearVest(String name, String weight) {
return '$name, $weight';
}
@override
String weightKg(String weight) {
return '$weight kg';

View file

@ -23,6 +23,7 @@ ByteData encodePlace(PlaceContent content) => ByteData.sublistView(
'name': ?item.name,
'count': item.count,
'grams': ?item.grams,
'gramsUpTo': ?item.gramsUpTo,
if (item.weights.isNotEmpty)
'weights': [
for (final grams
@ -67,6 +68,10 @@ PlaceContent? decodePlace(ByteData payload) {
final int grams when grams > 0 => grams,
_ => null,
},
gramsUpTo: switch ((fields['grams'], fields['gramsUpTo'])) {
(final int from, final int upTo) when upTo > from => upTo,
_ => null,
},
weights: {
for (final pair in fields['weights'] as List<Object?>? ?? [])
if (pair case [final int grams, final int count]

View file

@ -25,6 +25,9 @@ enum EquipmentKind() {
/// Kettlebells, by weight and count.
kettlebells,
/// A weight vest, of one weight or adjustable (ADR 0034, D7).
weightVest,
/// A bench.
bench,
@ -58,8 +61,12 @@ enum EquipmentKind() {
_ => false,
};
/// Whether a single weight of its own is written for it.
bool get weighs => loadable || this == weightVest;
/// 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.
/// 20 kg, a handle of 2 kg, an EZ bar of 10 kg, a trap bar of 25 kg, a
/// weight vest of 10 kg.
EquipmentItem get usual => EquipmentItem(
kind: this,
grams: switch (this) {
@ -67,6 +74,7 @@ enum EquipmentKind() {
ezBar => 10000,
trapBar => 25000,
dumbbellHandle => 2000,
weightVest => 10000,
_ => null,
},
count: this == dumbbellHandle ? 2 : 1,
@ -75,29 +83,33 @@ enum EquipmentKind() {
/// 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).
/// ([grams]; an adjustable vest's lightest, [gramsUpTo] its heaviest), 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 int? gramsUpTo,
final Map<int, int> weights = const {},
}) with Equatable {
EquipmentItem copyWith({
String? name,
int? count,
int? grams,
int? gramsUpTo,
Map<int, int>? weights,
}) => EquipmentItem(
kind: kind,
name: name ?? this.name,
count: count ?? this.count,
grams: grams ?? this.grams,
gramsUpTo: gramsUpTo ?? this.gramsUpTo,
weights: weights ?? this.weights,
);
@override
List<Object?> get props => [kind, name, count, grams, weights];
List<Object?> get props => [kind, name, count, grams, gramsUpTo, weights];
}
/// A place the person trains at (ADR 0034): its [name] and its [items].

View file

@ -68,8 +68,14 @@ extension EquipmentTexts on AppLocalizations {
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)),
if (item.grams case final grams? when item.kind.weighs)
switch (item.gramsUpTo) {
final upTo? => weightRangeKg(
kilograms(grams, locale),
kilograms(upTo, locale),
),
null => weightKg(kilograms(grams, locale)),
},
],
].join(item.kind.byWeight ? ', ' : ' ');
}
@ -83,6 +89,7 @@ IconData equipmentIcon(EquipmentKind kind) => switch (kind) {
EquipmentKind.fixedDumbbells => LucideIcons.dumbbell,
EquipmentKind.plates => LucideIcons.disc,
EquipmentKind.kettlebells => LucideIcons.weight,
EquipmentKind.weightVest => LucideIcons.shirt,
EquipmentKind.bench => LucideIcons.armchair,
EquipmentKind.rack => LucideIcons.columns3,
EquipmentKind.pullUpBar || EquipmentKind.dipBars => LucideIcons.grip,

View file

@ -17,8 +17,8 @@ final class const ItemEdited(final EquipmentItem item)
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
/// many; a loadable one's own weight; a weight vest's, up to how much it
/// adjusts; 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,
@ -45,6 +45,7 @@ class _ItemSheetState() extends State<_ItemSheet> {
late var _count = widget.item.count;
late final _name = TextEditingController(text: widget.item.name ?? '');
late final _grams = TextEditingController();
late final _gramsUpTo = TextEditingController();
final _weights = <_Weight>[];
var _started = false;
@ -59,6 +60,9 @@ class _ItemSheetState() extends State<_ItemSheet> {
if (widget.item.grams case final grams?) {
_grams.text = kilograms(grams, locale);
}
if (widget.item.gramsUpTo case final upTo?) {
_gramsUpTo.text = kilograms(upTo, locale);
}
final weights = widget.item.weights.keys.toList()
..sort((a, b) => b.compareTo(a));
_weights.addAll([
@ -71,6 +75,7 @@ class _ItemSheetState() extends State<_ItemSheet> {
void dispose() {
_name.dispose();
_grams.dispose();
_gramsUpTo.dispose();
for (final weight in _weights) {
weight.field.dispose();
}
@ -79,10 +84,22 @@ class _ItemSheetState() extends State<_ItemSheet> {
EquipmentKind get _kind => widget.item.kind;
bool get _vest => _kind == EquipmentKind.weightVest;
/// Whether the vest's upper weight is empty (one weight) or above its
/// weight.
bool get _upToValid {
if (_gramsUpTo.text.trim().isEmpty) {
return true;
}
final (from, upTo) = (gramsOf(_grams.text), gramsOf(_gramsUpTo.text));
return upTo != null && (from == null || upTo > from);
}
/// 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) {
final grams = _kind.weighs ? gramsOf(_grams.text) : null;
if (_kind.weighs && grams == null || _vest && !_upToValid) {
return null;
}
final weights = <int, int>{};
@ -102,6 +119,7 @@ class _ItemSheetState() extends State<_ItemSheet> {
name: _kind == EquipmentKind.own ? name : null,
count: _kind.byWeight ? 1 : _count,
grams: grams,
gramsUpTo: _vest ? gramsOf(_gramsUpTo.text) : null,
weights: weights,
);
}
@ -133,7 +151,7 @@ class _ItemSheetState() extends State<_ItemSheet> {
),
const SizedBox(height: 8),
],
if (!_kind.byWeight)
if (!_kind.byWeight && !_vest)
Row(
children: [
Expanded(child: Text(l10n.itemHowMany)),
@ -150,7 +168,7 @@ class _ItemSheetState() extends State<_ItemSheet> {
),
],
),
if (_kind.loadable) ...[
if (_kind.weighs) ...[
const SizedBox(height: 16),
TextField(
controller: _grams,
@ -159,7 +177,7 @@ class _ItemSheetState() extends State<_ItemSheet> {
),
inputFormatters: [kgFormatter],
decoration: InputDecoration(
labelText: l10n.itemOwnWeight,
labelText: _vest ? l10n.itemVestWeight : l10n.itemOwnWeight,
suffixText: 'kg',
errorText: gramsOf(_grams.text) == null
? l10n.itemWeightInvalid
@ -168,6 +186,23 @@ class _ItemSheetState() extends State<_ItemSheet> {
onChanged: (_) => setState(() {}),
),
],
if (_vest) ...[
const SizedBox(height: 16),
TextField(
controller: _gramsUpTo,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [kgFormatter],
decoration: InputDecoration(
labelText: l10n.itemVestUpTo,
helperText: l10n.itemVestUpToHint,
suffixText: 'kg',
errorText: _upToValid ? null : l10n.itemVestUpToInvalid,
),
onChanged: (_) => setState(() {}),
),
],
if (_kind.byWeight) ...[
Text(
l10n.itemWeights,

View file

@ -43,6 +43,12 @@ final class const TypedWeight(
List<Object?> get props => [grams, madeUp];
}
/// The weight vest at [place], which what the body lifts can add
/// (ADR 0034, D7); null without one.
EquipmentItem? vestAt(PlaceContent? place) => place?.items
.where((item) => item.kind == EquipmentKind.weightVest)
.firstOrNull;
/// The bar or handles [load] takes plates on at [place], with the
/// implements at once; null where it has none.
({int bar, int implements})? _barFor(ExerciseLoad load, PlaceContent place) {

View file

@ -19,6 +19,7 @@ import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.d
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_session.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/weight_up.dart';
import 'package:halkyon_train_flutter/src/feature/strength/widget/strength_texts.dart';
import 'package:halkyon_train_flutter/src/feature/strength/widget/vest_chip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -447,6 +448,17 @@ class _NextSetState() extends State<_NextSet> {
],
],
),
if (vestAt(place) case final vest?
when _exercise.load == ExerciseLoad.body) ...[
const SizedBox(height: 8),
VestChip(
vest: vest,
onWear: (grams) => _kg.text = kilograms(
grams,
Localizations.localeOf(context).toString(),
),
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: () {

View file

@ -0,0 +1,36 @@
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:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Offers [vest] at a set of the body (ADR 0034, D7): tapped, [onWear]
/// gets its weight, an adjustable one's lightest, to change if it's set
/// heavier.
class const VestChip({
required final EquipmentItem vest,
required final void Function(int grams) onWear,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
return Align(
alignment: AlignmentDirectional.centerStart,
child: ActionChip(
avatar: const Icon(LucideIcons.shirt, size: 18),
label: Text(
l10n.strengthWearVest(
l10n.itemTitle(vest),
l10n.itemSummary(vest, locale),
),
),
onPressed: switch (vest.grams) {
final grams? => () => onWear(grams),
null => null,
},
),
);
}
}

View file

@ -97,4 +97,33 @@ void main() {
expect(decodePlace(_bytes([1, 2])), isNull);
expect(decodePlace(ByteData(0)), isNull);
});
test('a weight vest keeps its weight, and how far an adjustable one goes '
'(ADR 0034, D7)', () {
const place = PlaceContent(
name: 'Home',
items: [
EquipmentItem(
kind: EquipmentKind.weightVest,
grams: 6000,
gramsUpTo: 10000,
),
EquipmentItem(kind: EquipmentKind.weightVest, grams: 8000),
],
);
expect(decodePlace(encodePlace(place)), place);
});
test("an upper weight that isn't above the vest's weight is left out", () {
final place = decodePlace(
_bytes({
'schemaVersion': 1,
'name': 'Home',
'items': [
{'kind': 'weightVest', 'count': 1, 'grams': 8000, 'gramsUpTo': 8000},
],
}),
);
expect(place!.items.single.gramsUpTo, isNull);
});
}

View file

@ -0,0 +1,52 @@
import 'package:flutter_test/flutter_test.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/strength/widget/vest_chip.dart';
import 'package:material_ui/material_ui.dart';
/// The vest offered at a set of the body (ADR 0034, D7).
void main() {
Future<void> pump(
WidgetTester tester,
EquipmentItem vest,
void Function(int) onWear,
) => tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: VestChip(vest: vest, onWear: onWear),
),
),
);
testWidgets('an adjustable vest shows its range and gives its lightest', (
tester,
) async {
int? worn;
await pump(
tester,
const EquipmentItem(
kind: EquipmentKind.weightVest,
grams: 6000,
gramsUpTo: 10000,
),
(grams) => worn = grams,
);
expect(find.text('Weight vest, 6–10 kg'), findsOneWidget);
await tester.tap(find.byType(ActionChip));
expect(worn, 6000);
});
testWidgets('a vest of one weight shows that weight', (tester) async {
await pump(
tester,
const EquipmentItem(kind: EquipmentKind.weightVest, grams: 8000),
(_) {},
);
expect(find.text('Weight vest, 8 kg'), findsOneWidget);
});
}

View file

@ -74,4 +74,18 @@ void main() {
expect(series.last.change, const PlateChange(off: [], on: [10000]));
expect(plateSeries(ExerciseLoad.dumbbellPair, [7000], home), isNull);
});
test(
'a place offers its weight vest to what the body lifts (ADR 0034, D7)',
() {
const vest = EquipmentItem(
kind: EquipmentKind.weightVest,
grams: 6000,
gramsUpTo: 10000,
);
expect(vestAt(const PlaceContent(name: 'Home', items: [vest])), vest);
expect(vestAt(home), isNull);
expect(vestAt(null), isNull);
},
);
}