From 9df78dcb97631bc773540d43f684aebbbde8290e Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:44:09 +0200 Subject: [PATCH 01/10] feat(blender): price a hand-entered gas fill from start and end pressure Refs #2302 --- .../domain/blending/blend_billing.dart | 49 +++++++++ .../domain/blend_billing_test.dart | 99 +++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/lib/features/gas_calculators/domain/blending/blend_billing.dart b/lib/features/gas_calculators/domain/blending/blend_billing.dart index d5f4c2a887..6d5d4f1392 100644 --- a/lib/features/gas_calculators/domain/blending/blend_billing.dart +++ b/lib/features/gas_calculators/domain/blending/blend_billing.dart @@ -110,3 +110,52 @@ BillingResult computeBlendCost({ return BillingResult(lines: lines, total: complete ? total : null); } + +/// What a single hand-entered gas fill costs. +class ManualGasFillCost { + const ManualGasFillCost({ + required this.addedBar, + required this.freeGasLiters, + required this.cost, + }); + + /// End pressure minus start pressure. + final double addedBar; + + /// Free gas at the surface, in litres: the same ideal + /// `water volume x bar delivered` [computeBlendCost] charges for. + final double freeGasLiters; + + final double cost; +} + +/// Price one gas filled by hand into a cylinder of [waterLiters] water +/// capacity, from [startBar] up to [endBar], at [pricePer100] per 100 litres +/// of free gas (issue #2302). +/// +/// A gas with no price is charged at 0 rather than left unpriced: the line +/// was entered by hand for a gas the blender chose, and the amount is shown +/// before it is saved, so a zero is visible where a silent gap in the total +/// would not be. +/// +/// Null when the input cannot describe a fill: no cylinder, a negative start +/// pressure, an end pressure not above the start, or a non-finite value. +ManualGasFillCost? manualGasFillCost({ + required double waterLiters, + required double startBar, + required double endBar, + required double? pricePer100, +}) { + if (!waterLiters.isFinite || !startBar.isFinite || !endBar.isFinite) { + return null; + } + if (waterLiters <= 0 || startBar < 0 || endBar <= startBar) return null; + final addedBar = endBar - startBar; + final liters = waterLiters * addedBar; + final price = pricePer100 != null && pricePer100.isFinite ? pricePer100 : 0; + return ManualGasFillCost( + addedBar: addedBar, + freeGasLiters: liters, + cost: liters / 100 * price, + ); +} diff --git a/test/features/gas_calculators/domain/blend_billing_test.dart b/test/features/gas_calculators/domain/blend_billing_test.dart index 717622b9ea..fb629f1333 100644 --- a/test/features/gas_calculators/domain/blend_billing_test.dart +++ b/test/features/gas_calculators/domain/blend_billing_test.dart @@ -178,4 +178,103 @@ void main() { expect(result.total, isNotNull); }); }); + + group('manualGasFillCost', () { + test('prices the pressure between start and end, issue #2302 example', () { + final cost = manualGasFillCost( + waterLiters: 12, + startBar: 50, + endBar: 200, + pricePer100: 1.5, + )!; + expect(cost.addedBar, 150); + expect(cost.freeGasLiters, 1800); + expect(cost.cost, closeTo(27, 1e-9)); + }); + + test('an empty cylinder is filled from 0', () { + final cost = manualGasFillCost( + waterLiters: 10, + startBar: 0, + endBar: 232, + pricePer100: 2, + )!; + expect(cost.addedBar, 232); + expect(cost.cost, closeTo(46.4, 1e-9)); + }); + + test('a gas without a price is charged at 0, not left unpriced', () { + final cost = manualGasFillCost( + waterLiters: 12, + startBar: 0, + endBar: 100, + pricePer100: null, + )!; + expect(cost.freeGasLiters, 1200); + expect(cost.cost, 0); + }); + + test('an end pressure not above the start pressure is rejected', () { + expect( + manualGasFillCost( + waterLiters: 12, + startBar: 100, + endBar: 100, + pricePer100: 1, + ), + isNull, + ); + expect( + manualGasFillCost( + waterLiters: 12, + startBar: 150, + endBar: 100, + pricePer100: 1, + ), + isNull, + ); + }); + + test('a negative start pressure or no cylinder is rejected', () { + expect( + manualGasFillCost( + waterLiters: 12, + startBar: -1, + endBar: 100, + pricePer100: 1, + ), + isNull, + ); + expect( + manualGasFillCost( + waterLiters: 0, + startBar: 0, + endBar: 100, + pricePer100: 1, + ), + isNull, + ); + }); + + test('non-finite input is rejected rather than priced', () { + expect( + manualGasFillCost( + waterLiters: double.nan, + startBar: 0, + endBar: 100, + pricePer100: 1, + ), + isNull, + ); + expect( + manualGasFillCost( + waterLiters: 12, + startBar: 0, + endBar: double.infinity, + pricePer100: 1, + ), + isNull, + ); + }); + }); } From 27831a5ee576fad065cb3108823d6615b8bdbd87 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:57:41 +0200 Subject: [PATCH 02/10] feat(blender): add a line as a free amount or a priced gas fill The line form now switches between a free amount and a gas fill. A gas fill picks O2, helium or the topup gas, takes the cylinder volume, a start pressure and an end pressure seeded from the cylinder preset's working pressure, and prices the pressure filled at that gas's configured rate. The amount is shown as text and saved as a fixed value; a gas without a price is charged at 0. The fill is stored as a single itemised gas line carrying its role and start pressure, so it can be reopened with its values. The informational custom mix is retired. Refs #2302 --- .../domain/blending/billed_fill.dart | 88 ++- .../blender_invoice_archive_detail_page.dart | 17 - .../widgets/blender/blender_invoice_card.dart | 346 +---------- .../blender/blender_line_edit_sheet.dart | 545 ++++++++++++++++++ lib/l10n/arb/app_ar.arb | 13 +- lib/l10n/arb/app_de.arb | 13 +- lib/l10n/arb/app_en.arb | 27 +- lib/l10n/arb/app_es.arb | 13 +- lib/l10n/arb/app_fr.arb | 13 +- lib/l10n/arb/app_he.arb | 13 +- lib/l10n/arb/app_hu.arb | 13 +- lib/l10n/arb/app_it.arb | 13 +- lib/l10n/arb/app_localizations.dart | 68 ++- lib/l10n/arb/app_localizations_ar.dart | 43 +- lib/l10n/arb/app_localizations_de.dart | 43 +- lib/l10n/arb/app_localizations_en.dart | 43 +- lib/l10n/arb/app_localizations_es.dart | 43 +- lib/l10n/arb/app_localizations_fr.dart | 43 +- lib/l10n/arb/app_localizations_he.dart | 43 +- lib/l10n/arb/app_localizations_hu.dart | 43 +- lib/l10n/arb/app_localizations_it.dart | 43 +- lib/l10n/arb/app_localizations_nl.dart | 43 +- lib/l10n/arb/app_localizations_pt.dart | 43 +- lib/l10n/arb/app_localizations_zh.dart | 40 +- lib/l10n/arb/app_nl.arb | 13 +- lib/l10n/arb/app_pt.arb | 13 +- lib/l10n/arb/app_zh.arb | 13 +- .../gas_calculators/blender_invoice_test.dart | 434 +++++++++++--- 28 files changed, 1610 insertions(+), 515 deletions(-) create mode 100644 lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart diff --git a/lib/features/gas_calculators/domain/blending/billed_fill.dart b/lib/features/gas_calculators/domain/blending/billed_fill.dart index 3600869958..30276c8cd7 100644 --- a/lib/features/gas_calculators/domain/blending/billed_fill.dart +++ b/lib/features/gas_calculators/domain/blending/billed_fill.dart @@ -1,3 +1,5 @@ +import 'package:submersion/features/gas_calculators/domain/blending/blender_gas_role.dart'; + /// Enough for a busy Saturday without letting a synced blob grow forever. const int kMaxBilledFills = 100; @@ -9,6 +11,8 @@ class BilledGasLine { required this.cost, this.freeGasLiters, this.cylinderLiters, + this.role, + this.startBar, }); /// The gas as it was labelled when the fill was saved, e.g. "He" or @@ -34,12 +38,27 @@ class BilledGasLine { /// recovered from settings that have since moved on. final double? cylinderLiters; + /// The fill gas a hand-entered gas fill was billed for (issue #2302). + /// Null on every line the blender computed: set only by "Add a line", so + /// editing that line can reopen the form on the same gas. + final BlenderGasRole? role; + + /// The pressure a hand-entered gas fill started from, in bar. The end + /// pressure is [startBar] + [addedBar] rather than a field of its own, so + /// the two can never disagree. + final double? startBar; + + /// The pressure a hand-entered gas fill ended at, when its start is known. + double? get endBar => startBar == null ? null : startBar! + addedBar; + Map toJson() => { 'gas': gas, 'addedBar': addedBar, if (cost != null) 'cost': cost, if (freeGasLiters != null) 'freeGasLiters': freeGasLiters, if (cylinderLiters != null) 'cylinderLiters': cylinderLiters, + if (role != null) 'role': role!.name, + if (startBar != null) 'startBar': startBar, }; static BilledGasLine? fromJson(Object? json) { @@ -50,49 +69,16 @@ class BilledGasLine { final cost = json['cost']; final liters = json['freeGasLiters']; final cylinderLiters = json['cylinderLiters']; + final role = json['role']; + final startBar = json['startBar']; return BilledGasLine( gas: gas, addedBar: bar.toDouble(), cost: cost is num ? cost.toDouble() : null, freeGasLiters: liters is num ? liters.toDouble() : null, cylinderLiters: cylinderLiters is num ? cylinderLiters.toDouble() : null, - ); - } -} - -/// The cylinder and mix behind a manually entered bill line. -/// -/// Kept alongside the free-typed [BilledFill.total] rather than replacing it: -/// the amount charged at a real counter is still whatever the blender typed, -/// this only records what was actually filled so the line reads as more than -/// a bare number (issue #1335). -class BilledCustomMix { - const BilledCustomMix({ - required this.cylinderLiters, - required this.o2, - required this.he, - }); - - final double cylinderLiters; - final double o2; - final double he; - - Map toJson() => { - 'cylinderLiters': cylinderLiters, - 'o2': o2, - 'he': he, - }; - - static BilledCustomMix? fromJson(Object? json) { - if (json is! Map) return null; - final liters = json['cylinderLiters']; - final o2 = json['o2']; - final he = json['he']; - if (liters is! num || o2 is! num || he is! num) return null; - return BilledCustomMix( - cylinderLiters: liters.toDouble(), - o2: o2.toDouble(), - he: he.toDouble(), + role: role is String ? BlenderGasRole.fromName(role) : null, + startBar: startBar is num ? startBar.toDouble() : null, ); } } @@ -110,7 +96,6 @@ class BilledFill { required this.label, required this.lines, required this.total, - this.customMix, }); final String id; @@ -119,19 +104,20 @@ class BilledFill { /// line such as "O2 analyser cell". final String label; - /// Empty for a manually added line: there is no fill behind it to itemise. + /// Empty for a free-amount line: there is no fill behind it to itemise. final List lines; /// Null when the fill was saved before every gas had a price. final double? total; - /// The cylinder and mix entered for a manual line, when the blender chose - /// to record one. Always null for a computed fill: [lines] already - /// itemises it. - final BilledCustomMix? customMix; - + /// A free-amount line, typed in by hand with nothing to itemise. bool get isManual => lines.isEmpty; + /// The single gas line of a gas fill entered through "Add a line" (issue + /// #2302), or null for a free-amount line or a fill the blender computed. + BilledGasLine? get manualGasLine => + lines.length == 1 && lines.single.role != null ? lines.single : null; + /// [clearTotal] is how an amount gets removed. Null is meaningful here: it /// marks a line as not yet priced, which is what makes the grand total /// report itself incomplete, and `total ?? this.total` could not express it, @@ -141,21 +127,19 @@ class BilledFill { /// gives up compile-time typing: `copyWith(total: 40)` then compiles and /// throws at run time on the int literal. /// - /// [clearCustomMix] follows the same shape: re-editing a manual line to - /// remove its mix has to be expressible, and `customMix ?? this.customMix` - /// could not tell "unchanged" from "cleared" any more than `total` could. + /// [lines] is replaced only when given: a hand-entered line can switch + /// between a free amount and a gas fill when it is edited (issue #2302), + /// while a computed fill keeps the itemisation it was saved with. BilledFill copyWith({ String? label, + List? lines, double? total, bool clearTotal = false, - BilledCustomMix? customMix, - bool clearCustomMix = false, }) => BilledFill( id: id, label: label ?? this.label, - lines: lines, + lines: lines ?? this.lines, total: clearTotal ? null : (total ?? this.total), - customMix: clearCustomMix ? null : (customMix ?? this.customMix), ); Map toJson() => { @@ -163,7 +147,6 @@ class BilledFill { 'label': label, 'lines': lines.map((l) => l.toJson()).toList(), if (total != null) 'total': total, - if (customMix != null) 'customMix': customMix!.toJson(), }; static BilledFill? fromJson(Object? json) { @@ -183,7 +166,6 @@ class BilledFill { .toList() : const [], total: total is num ? total.toDouble() : null, - customMix: BilledCustomMix.fromJson(json['customMix']), ); } } diff --git a/lib/features/gas_calculators/presentation/pages/blender_invoice_archive_detail_page.dart b/lib/features/gas_calculators/presentation/pages/blender_invoice_archive_detail_page.dart index db8490a48e..3483a928d7 100644 --- a/lib/features/gas_calculators/presentation/pages/blender_invoice_archive_detail_page.dart +++ b/lib/features/gas_calculators/presentation/pages/blender_invoice_archive_detail_page.dart @@ -3,8 +3,6 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; -import 'package:submersion/features/dive_log/domain/entities/dive.dart' - show GasMix; import 'package:submersion/features/gas_calculators/domain/blending/billed_fill.dart'; import 'package:submersion/features/gas_calculators/presentation/providers/gas_blender_providers.dart'; import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_archived_invoice_tile.dart'; @@ -171,21 +169,6 @@ class BlenderInvoiceArchiveDetailPage extends ConsumerWidget { units: units, decimals: decimals, ), - // A manual (lump-sum) fill has no gas lines, only the mix and - // cylinder it was filled to -- the running invoice shows that same - // row after its gas lines, and the archive lost it entirely - // (Copilot review, issue #1876 follow-up). - if (fill.customMix case final mix?) - Padding( - padding: const EdgeInsets.only(left: 16, top: 2), - child: Text( - '${units.formatVolume(mix.cylinderLiters)} · ' - '${formatPreciseMix(context, GasMix(o2: mix.o2, he: mix.he))}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), ], ), ); diff --git a/lib/features/gas_calculators/presentation/widgets/blender/blender_invoice_card.dart b/lib/features/gas_calculators/presentation/widgets/blender/blender_invoice_card.dart index ecf85a0c18..aa12b935fb 100644 --- a/lib/features/gas_calculators/presentation/widgets/blender/blender_invoice_card.dart +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_invoice_card.dart @@ -2,7 +2,6 @@ import 'dart:ui' as ui show ImageByteFormat; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/services/export/export_service.dart'; @@ -10,22 +9,19 @@ import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/utils/number_input.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; -import 'package:submersion/features/dive_log/domain/entities/dive.dart' - show GasMix; import 'package:submersion/features/gas_calculators/domain/blending/billed_fill.dart'; import 'package:submersion/features/gas_calculators/domain/blending/blender_gas_role.dart'; -import 'package:submersion/features/gas_calculators/domain/blending/blender_preferences.dart'; import 'package:submersion/features/gas_calculators/domain/blending/flush_fee.dart'; import 'package:submersion/features/gas_calculators/presentation/gas_calculator_tools.dart'; import 'package:submersion/features/gas_calculators/presentation/providers/gas_blender_providers.dart'; import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_billed_line_row.dart'; import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_formatting.dart'; import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_invoice_export_sheet.dart'; +import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart'; import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_section_title.dart'; import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_table_style.dart'; import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_volume_conversion.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; -import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/widgets/app_date_picker.dart'; @@ -779,17 +775,6 @@ class _BlenderInvoiceCardState extends ConsumerState { units: units, decimals: decimals, ), - if (fill.customMix case final mix?) - Padding( - padding: const EdgeInsets.only(left: 16, top: 2), - child: Text( - '${units.formatVolume(mix.cylinderLiters)} · ' - '${formatPreciseMix(context, GasMix(o2: mix.o2, he: mix.he))}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), ], ), ); @@ -840,16 +825,18 @@ class _BlenderInvoiceCardState extends ConsumerState { saveBlenderPreferences(ref); } - /// Edit an existing line, or add a manual one when [fill] is null. + /// Edit an existing line, or add a hand-entered one when [fill] is null. /// - /// The amount stays editable on computed fills too: rounding and the - /// occasional discount happen at a real counter, and re-blending the - /// cylinder to change what it costs would be absurd. + /// The amount stays editable on computed fills and free-amount lines: + /// rounding and the occasional discount happen at a real counter, and + /// re-blending the cylinder to change what it costs would be absurd. A gas + /// fill entered here is the exception, priced from what was filled + /// (issue #2302). Future _editLine(BilledFill? fill) async { - final edited = await showModalBottomSheet<_LineEdit>( + final edited = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => _LineEditSheet(fill: fill), + builder: (context) => BlenderLineEditSheet(fill: fill), ); if (edited == null) return; @@ -860,9 +847,8 @@ class _BlenderInvoiceCardState extends ConsumerState { BilledFill( id: DateTime.now().microsecondsSinceEpoch.toString(), label: edited.label, - lines: const [], + lines: edited.lines ?? const [], total: edited.amount, - customMix: edited.customMix, ), ); } else { @@ -871,10 +857,9 @@ class _BlenderInvoiceCardState extends ConsumerState { if (f.id == fill.id) f.copyWith( label: edited.label, + lines: edited.lines, total: edited.amount, clearTotal: edited.amount == null, - customMix: edited.customMix, - clearCustomMix: edited.customMix == null, ) else f, @@ -940,312 +925,3 @@ class _BlenderInvoiceCardState extends ConsumerState { /// The two actions offered by a fill line's overflow menu. enum _FillLineAction { edit, delete } - -/// What the edit sheet hands back. -class _LineEdit { - const _LineEdit({required this.label, required this.amount, this.customMix}); - final String label; - final double? amount; - final BilledCustomMix? customMix; -} - -/// Owns its own controllers, and disposes them in its own State. -/// -/// Creating them in the caller and disposing on the sheet's future looks -/// equivalent and is not: the future completes when the route is popped, while -/// the exit transition keeps rebuilding these fields for several more frames -/// against a controller that is already gone. -/// -/// A scrollable, keyboard-aware bottom sheet rather than the fixed-size -/// `AlertDialog` this replaced: a cylinder row and an O2/He row roughly -/// double the field count, and a taller fixed dialog risks overflow once the -/// keyboard is up on the narrowest phone the app supports (issue #1335). -class _LineEditSheet extends ConsumerStatefulWidget { - const _LineEditSheet({required this.fill}); - - final BilledFill? fill; - - @override - ConsumerState<_LineEditSheet> createState() => _LineEditSheetState(); -} - -class _LineEditSheetState extends ConsumerState<_LineEditSheet> { - late final TextEditingController _label; - late final TextEditingController _amount; - late final TextEditingController _cylinder; - late final TextEditingController _o2; - late final TextEditingController _he; - - /// Only a new line or one that is still a manual/custom-mix entry offers - /// the cylinder and mix fields. A computed fill's gases are already - /// itemised in [BilledFill.lines]; editing them here would let the label - /// and the itemisation disagree. - bool get _showMix => widget.fill == null || widget.fill!.isManual; - - String? _error; - - @override - void initState() { - super.initState(); - final fill = widget.fill; - final settings = ref.read(settingsProvider); - _label = TextEditingController(text: fill?.label ?? ''); - _amount = TextEditingController( - text: fill?.total == null ? '' : formatRoundedForInput(fill!.total!, 2), - ); - final mix = fill?.customMix; - final double cylinderLiters = - mix?.cylinderLiters ?? ref.read(blenderCylinderLitersProvider); - _cylinder = TextEditingController( - text: formatRoundedForInput( - litersToDisplayVolume(cylinderLiters, settings), - 2, - ), - ); - _o2 = TextEditingController(text: formatRoundedForInput(mix?.o2 ?? 21, 1)); - _he = TextEditingController(text: formatRoundedForInput(mix?.he ?? 0, 1)); - } - - @override - void dispose() { - _label.dispose(); - _amount.dispose(); - _cylinder.dispose(); - _o2.dispose(); - _he.dispose(); - super.dispose(); - } - - void _submit() { - final label = _label.text.trim(); - final amount = parseUserDecimal(_amount.text); - BilledCustomMix? customMix; - if (_showMix) { - final settings = ref.read(settingsProvider); - final liters = parseUserDecimal(_cylinder.text); - final o2 = parseUserDecimal(_o2.text); - final he = parseUserDecimal(_he.text); - if (liters != null && o2 != null && he != null) { - if (!MixTemplate(o2: o2, he: he).isValid) { - setState( - () => _error = context.l10n.gasCalculators_blender_error_invalidMix, - ); - return; - } - customMix = BilledCustomMix( - cylinderLiters: displayVolumeToLiters(liters, settings), - o2: o2, - he: he, - ); - } - } - final effectiveLabel = label.isNotEmpty - ? label - : customMix != null - ? formatPreciseMix(context, GasMix(o2: customMix.o2, he: customMix.he)) - : ''; - // Nothing to name the line with. Said out loud rather than returned on - // quietly: a Save that does nothing and explains nothing reads as a - // broken button (PR #1359 review), the same reasoning MixTemplateManager - // applies to a half-typed mix. - if (effectiveLabel.isEmpty) { - setState( - () => _error = context.l10n.gasCalculators_blender_lineNeedsDescription, - ); - return; - } - Navigator.of(context).pop( - _LineEdit(label: effectiveLabel, amount: amount, customMix: customMix), - ); - } - - @override - Widget build(BuildContext context) { - final fill = widget.fill; - final settings = ref.watch(settingsProvider); - final units = UnitFormatter(settings); - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - fill == null - ? context.l10n.gasCalculators_blender_addManualLine - : context.l10n.gasCalculators_blender_editLine(fill.label), - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - TextField( - key: const Key('blender-line-description'), - controller: _label, - autofocus: true, - decoration: InputDecoration( - labelText: context.l10n.gasCalculators_blender_lineDescription, - isDense: true, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - if (_showMix) ...[ - _cylinderRow(context, settings, units), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _numberField( - context, - _o2, - context.l10n.gasCalculators_blender_o2, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _numberField( - context, - _he, - context.l10n.gasCalculators_blender_he, - ), - ), - ], - ), - const SizedBox(height: 12), - ], - TextField( - key: const Key('blender-line-amount'), - controller: _amount, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - onSubmitted: (_) => _submit(), - decoration: InputDecoration( - labelText: context.l10n.gasCalculators_blender_lineAmount, - isDense: true, - border: const OutlineInputBorder(), - ), - ), - if (_error != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _error!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: _submit, - child: Text(context.l10n.common_action_save), - ), - ], - ), - ), - ); - } - - Widget _cylinderRow( - BuildContext context, - AppSettings settings, - UnitFormatter units, - ) { - // Sourced from the diver's global tank presets (issue #1335 follow-up), - // same as BlenderBillingCard._cylinderRow: the blender keeps no cylinder - // vault of its own, so this sheet's picker reads the same list. - final presetsAsync = ref.watch(tankPresetsProvider); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - key: const Key('blender-line-cylinder'), - controller: _cylinder, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')), - ], - decoration: InputDecoration( - labelText: - '${context.l10n.gasCalculators_blender_cylinderVolume} ' - '(${units.volumeSymbol})', - isDense: true, - border: const OutlineInputBorder(), - ), - ), - ), - const SizedBox(width: 8), - presetsAsync.when( - loading: () => const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - error: (error, stackTrace) => IconButton( - icon: const Icon(Icons.error_outline), - tooltip: context.l10n.gasCalculators_blender_cylinderPresets, - onPressed: null, - ), - data: (presets) => PopupMenuButton( - key: const Key('blender-line-cylinder-presets'), - tooltip: context.l10n.gasCalculators_blender_cylinderPresets, - position: PopupMenuPosition.under, - itemBuilder: (context) => [ - for (final preset in presets) - PopupMenuItem( - value: preset.volumeLiters, - child: Text( - '${preset.displayName} ' - '(${units.formatTankVolume(preset.volumeLiters, null)})', - ), - ), - ], - onSelected: (liters) => setState( - () => _cylinder.text = formatRoundedForInput( - litersToDisplayVolume(liters, settings), - 2, - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.l10n.gasCalculators_blender_cylinderPresets), - const Icon(Icons.arrow_drop_down), - ], - ), - ), - ), - ), - ], - ); - } - - Widget _numberField( - BuildContext context, - TextEditingController controller, - String label, - ) { - return TextField( - controller: controller, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], - decoration: InputDecoration( - labelText: '$label (%)', - isDense: true, - border: const OutlineInputBorder(), - ), - ); - } -} diff --git a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart new file mode 100644 index 0000000000..0c93c80490 --- /dev/null +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart @@ -0,0 +1,545 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/currency.dart'; +import 'package:submersion/core/utils/number_input.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/gas_calculators/domain/blending/billed_fill.dart'; +import 'package:submersion/features/gas_calculators/domain/blending/blend_billing.dart'; +import 'package:submersion/features/gas_calculators/domain/blending/blender_gas_role.dart'; +import 'package:submersion/features/gas_calculators/presentation/providers/gas_blender_providers.dart'; +import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_formatting.dart'; +import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_volume_conversion.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; +import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The two kinds of line "Add a line" can put on the bill (issue #2302). +enum BlenderLineKind { + /// One gas filled by hand, priced from the cylinder, the pressure filled + /// and that gas's configured price. + gas, + + /// A lump sum typed in by hand, e.g. an O2 analyser cell. + amount, +} + +/// What [BlenderLineEditSheet] hands back. +class BlenderLineEdit { + const BlenderLineEdit({ + required this.label, + required this.amount, + this.lines, + }); + + final String label; + final double? amount; + + /// The line's itemisation: empty for a free amount, the single gas line of + /// a gas fill. Null leaves the edited fill's own lines untouched, which is + /// what a fill the blender computed needs. + final List? lines; +} + +/// Adds a line to the running bill, or edits one already on it. +/// +/// Owns its own controllers, and disposes them in its own State. +/// +/// Creating them in the caller and disposing on the sheet's future looks +/// equivalent and is not: the future completes when the route is popped, while +/// the exit transition keeps rebuilding these fields for several more frames +/// against a controller that is already gone. +/// +/// A scrollable, keyboard-aware bottom sheet rather than a fixed-size +/// `AlertDialog`: the gas fill needs a cylinder row and two pressure fields, +/// and a taller fixed dialog risks overflow once the keyboard is up on the +/// narrowest phone the app supports (issue #1335). +class BlenderLineEditSheet extends ConsumerStatefulWidget { + const BlenderLineEditSheet({super.key, required this.fill}); + + /// Null to add a new line. + final BilledFill? fill; + + @override + ConsumerState createState() => + _BlenderLineEditSheetState(); +} + +class _BlenderLineEditSheetState extends ConsumerState { + late final TextEditingController _label; + late final TextEditingController _amount; + late final TextEditingController _cylinder; + late final TextEditingController _startPressure; + late final TextEditingController _endPressure; + + late BlenderLineKind _kind; + late BlenderGasRole _role; + + String? _error; + + /// Only a new line or one entered by hand may pick its kind. A fill the + /// blender computed is already itemised in [BilledFill.lines]; offering a + /// gas or an amount here would let the label and the itemisation disagree. + bool get _kindEditable { + final fill = widget.fill; + return fill == null || fill.isManual || fill.manualGasLine != null; + } + + @override + void initState() { + super.initState(); + final fill = widget.fill; + final gasLine = fill?.manualGasLine; + final settings = ref.read(settingsProvider); + final units = UnitFormatter(settings); + + _kind = fill == null || gasLine != null + ? BlenderLineKind.gas + : BlenderLineKind.amount; + _role = gasLine?.role ?? BlenderGasRole.o2; + + // A label this sheet generated is left blank rather than kept as typed + // text, so changing the pressure or the gas regenerates it. + final generated = gasLine == null + ? null + : _generatedLabel( + gasLine.gas, + gasLine.cylinderLiters, + gasLine.addedBar, + units, + ); + _label = TextEditingController( + text: fill == null || fill.label == generated ? '' : fill.label, + ); + _amount = TextEditingController( + text: fill?.total == null || gasLine != null + ? '' + : formatRoundedForInput(fill!.total!, 2), + ); + final double cylinderLiters = + gasLine?.cylinderLiters ?? ref.read(blenderCylinderLitersProvider); + _cylinder = TextEditingController( + text: formatRoundedForInput( + litersToDisplayVolume(cylinderLiters, settings), + 2, + ), + ); + final double startBar = gasLine?.startBar ?? 0; + final double endBar = + gasLine?.endBar ?? ref.read(blenderTargetPressureProvider); + _startPressure = TextEditingController( + text: formatRoundedForInput(units.convertPressure(startBar), 0), + ); + _endPressure = TextEditingController( + text: formatRoundedForInput(units.convertPressure(endBar), 0), + ); + } + + @override + void dispose() { + _label.dispose(); + _amount.dispose(); + _cylinder.dispose(); + _startPressure.dispose(); + _endPressure.dispose(); + super.dispose(); + } + + String _generatedLabel( + String gasName, + double? cylinderLiters, + double addedBar, + UnitFormatter units, + ) => [ + gasName, + if (cylinderLiters != null) units.formatTankVolume(cylinderLiters, null), + units.formatPressure(addedBar), + ].join(' · '); + + /// The name the chosen gas is stored and printed under, the same one a + /// computed fill's line would carry for it. + String _gasName(BlenderGasRole role) => formatPreciseGasName( + context, + gasForRole(role, ref.read(blenderTopupO2PercentProvider)), + ); + + /// The dropdown entry for [role]. The topup gas names what it currently + /// holds, since that is configurable and not always air. + String _roleOption(BlenderGasRole role, double topupO2) { + final label = blenderGasRoleLabel(context, role); + if (role != BlenderGasRole.topup) return label; + return '$label (${formatPreciseMix(context, gasForRole(role, topupO2))})'; + } + + double? _priceFor(BlenderGasRole role, List prices) => + role.index < prices.length ? prices[role.index] : null; + + double? _cylinderLiters(AppSettings settings) { + final shown = parseUserDecimal(_cylinder.text); + if (shown == null || shown <= 0) return null; + return displayVolumeToLiters(shown, settings); + } + + /// The gas fill as currently entered, or null while it cannot be priced. + ManualGasFillCost? _gasCost( + AppSettings settings, + UnitFormatter units, + List prices, + ) { + final liters = _cylinderLiters(settings); + final start = parseUserDecimal(_startPressure.text); + final end = parseUserDecimal(_endPressure.text); + if (liters == null || start == null || end == null) return null; + return manualGasFillCost( + waterLiters: liters, + startBar: units.pressureToBar(start), + endBar: units.pressureToBar(end), + pricePer100: _priceFor(_role, prices), + ); + } + + void _submit() { + final label = _label.text.trim(); + if (!_kindEditable || _kind == BlenderLineKind.amount) { + if (label.isEmpty) { + setState( + () => + _error = context.l10n.gasCalculators_blender_lineNeedsDescription, + ); + return; + } + Navigator.of(context).pop( + BlenderLineEdit( + label: label, + amount: parseUserDecimal(_amount.text), + lines: _kindEditable ? const [] : null, + ), + ); + return; + } + + final settings = ref.read(settingsProvider); + final units = UnitFormatter(settings); + final liters = _cylinderLiters(settings); + if (liters == null) { + setState( + () => _error = context.l10n.gasCalculators_blender_lineNeedsCylinder, + ); + return; + } + final cost = _gasCost(settings, units, ref.read(blenderGasPricesProvider)); + if (cost == null) { + setState( + () => _error = context.l10n.gasCalculators_blender_lineInvalidPressure, + ); + return; + } + final gasName = _gasName(_role); + final startBar = units.pressureToBar( + parseUserDecimal(_startPressure.text)!, + ); + Navigator.of(context).pop( + BlenderLineEdit( + label: label.isNotEmpty + ? label + : _generatedLabel(gasName, liters, cost.addedBar, units), + amount: cost.cost, + lines: [ + BilledGasLine( + gas: gasName, + addedBar: cost.addedBar, + cost: cost.cost, + freeGasLiters: cost.freeGasLiters, + cylinderLiters: liters, + role: _role, + startBar: startBar, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final fill = widget.fill; + final settings = ref.watch(settingsProvider); + final units = UnitFormatter(settings); + final isGas = _kindEditable && _kind == BlenderLineKind.gas; + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + fill == null + ? context.l10n.gasCalculators_blender_addManualLine + : context.l10n.gasCalculators_blender_editLine(fill.label), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + if (_kindEditable) ...[ + SegmentedButton( + key: const Key('blender-line-kind'), + segments: [ + ButtonSegment( + value: BlenderLineKind.gas, + label: Text( + context.l10n.gasCalculators_blender_lineKindGas, + ), + ), + ButtonSegment( + value: BlenderLineKind.amount, + label: Text( + context.l10n.gasCalculators_blender_lineKindAmount, + ), + ), + ], + selected: {_kind}, + onSelectionChanged: (selection) => setState(() { + _kind = selection.single; + _error = null; + }), + ), + const SizedBox(height: 12), + ], + TextField( + key: const Key('blender-line-description'), + controller: _label, + autofocus: !isGas, + decoration: InputDecoration( + labelText: context.l10n.gasCalculators_blender_lineDescription, + helperText: isGas + ? context + .l10n + .gasCalculators_blender_lineDescriptionOptional + : null, + isDense: true, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + if (isGas) + ..._gasFields(context, settings, units) + else + TextField( + key: const Key('blender-line-amount'), + controller: _amount, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: context.l10n.gasCalculators_blender_lineAmount, + isDense: true, + border: const OutlineInputBorder(), + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _error!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _submit, + child: Text(context.l10n.common_action_save), + ), + ], + ), + ), + ); + } + + List _gasFields( + BuildContext context, + AppSettings settings, + UnitFormatter units, + ) { + final theme = Theme.of(context); + final prices = ref.watch(blenderGasPricesProvider); + final topupO2 = ref.watch(blenderTopupO2PercentProvider); + final currency = ref.watch(blenderCurrencyProvider); + final cost = _gasCost(settings, units, prices); + final unpriced = _priceFor(_role, prices) == null; + final resultStyle = theme.textTheme.titleSmall; + return [ + DropdownButtonFormField( + key: const Key('blender-line-gas'), + initialValue: _role, + decoration: InputDecoration( + labelText: context.l10n.gasCalculators_blender_lineGas, + isDense: true, + border: const OutlineInputBorder(), + ), + items: [ + for (final role in BlenderGasRole.values) + DropdownMenuItem( + value: role, + child: Text(_roleOption(role, topupO2)), + ), + ], + onChanged: (role) { + if (role != null) setState(() => _role = role); + }, + ), + const SizedBox(height: 12), + _cylinderRow(context, settings, units), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _numberField( + const Key('blender-line-start-pressure'), + _startPressure, + '${context.l10n.gasCalculators_blender_lineStartPressure} ' + '(${units.pressureSymbol})', + ), + ), + const SizedBox(width: 8), + Expanded( + child: _numberField( + const Key('blender-line-end-pressure'), + _endPressure, + '${context.l10n.gasCalculators_blender_lineEndPressure} ' + '(${units.pressureSymbol})', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + context.l10n.gasCalculators_blender_lineFillPressure( + cost == null ? '--' : units.formatPressure(cost.addedBar), + ), + key: const Key('blender-line-fill-pressure'), + style: resultStyle, + ), + const SizedBox(height: 4), + Text( + context.l10n.gasCalculators_blender_lineComputedAmount( + cost == null ? '--' : formatMoney(cost.cost, currency), + ), + key: const Key('blender-line-computed-amount'), + style: resultStyle?.copyWith(fontWeight: FontWeight.w700), + ), + if (unpriced) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + context.l10n.gasCalculators_blender_lineNoPrice, + key: const Key('blender-line-no-price'), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ]; + } + + Widget _cylinderRow( + BuildContext context, + AppSettings settings, + UnitFormatter units, + ) { + // Sourced from the diver's global tank presets (issue #1335 follow-up), + // same as BlenderBillingCard._cylinderRow: the blender keeps no cylinder + // vault of its own, so this sheet's picker reads the same list. + final presetsAsync = ref.watch(tankPresetsProvider); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _numberField( + const Key('blender-line-cylinder'), + _cylinder, + '${context.l10n.gasCalculators_blender_cylinderVolume} ' + '(${units.volumeSymbol})', + ), + ), + const SizedBox(width: 8), + presetsAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + error: (error, stackTrace) => IconButton( + icon: const Icon(Icons.error_outline), + tooltip: context.l10n.gasCalculators_blender_cylinderPresets, + onPressed: null, + ), + data: (presets) => PopupMenuButton( + key: const Key('blender-line-cylinder-presets'), + tooltip: context.l10n.gasCalculators_blender_cylinderPresets, + position: PopupMenuPosition.under, + itemBuilder: (context) => [ + for (final preset in presets) + PopupMenuItem( + value: preset, + child: Text( + '${preset.displayName} ' + '(${units.formatTankVolume(preset.volumeLiters, null)})', + ), + ), + ], + // A preset sets the end pressure too: its working pressure is + // what the cylinder is filled to (issue #2302). Still editable + // afterwards, since only the last gas of a blend reaches it. + onSelected: (preset) => setState(() { + _cylinder.text = formatRoundedForInput( + litersToDisplayVolume(preset.volumeLiters, settings), + 2, + ); + _endPressure.text = formatRoundedForInput( + units.convertPressure(preset.workingPressureBar), + 0, + ); + }), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.gasCalculators_blender_cylinderPresets), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _numberField(Key key, TextEditingController controller, String label) { + return TextField( + key: key, + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + onChanged: (_) => setState(() => _error = null), + decoration: InputDecoration( + labelText: label, + isDense: true, + border: const OutlineInputBorder(), + ), + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 1458357a37..2381faa521 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "إضافة بند", "gasCalculators_blender_lineDescription": "الوصف", "gasCalculators_blender_lineAmount": "المبلغ", - "gasCalculators_blender_lineNeedsDescription": "أدخل وصفًا، أو أسطوانة ومزيجًا.", + "gasCalculators_blender_lineNeedsDescription": "أدخل وصفًا.", + "gasCalculators_blender_lineKindGas": "تعبئة غاز", + "gasCalculators_blender_lineKindAmount": "مبلغ حر", + "gasCalculators_blender_lineGas": "التعبئة", + "gasCalculators_blender_lineStartPressure": "الضغط الابتدائي", + "gasCalculators_blender_lineEndPressure": "الضغط النهائي", + "gasCalculators_blender_lineFillPressure": "ضغط التعبئة: {pressure}", + "gasCalculators_blender_lineComputedAmount": "المبلغ: {amount}", + "gasCalculators_blender_lineNoPrice": "لم يُحدَّد سعر لهذا الغاز، لذا يُحتسب بصفر.", + "gasCalculators_blender_lineInvalidPressure": "يجب أن يكون الضغط النهائي أعلى من الضغط الابتدائي.", + "gasCalculators_blender_lineNeedsCylinder": "أدخل سعة الأسطوانة.", + "gasCalculators_blender_lineDescriptionOptional": "اختياري. إذا تُرك فارغًا، يُنشأ من التعبئة.", "gasCalculators_blender_export": "تصدير", "gasCalculators_blender_exportPdf": "تصدير كملف PDF", "gasCalculators_blender_exportImage": "تصدير كصورة", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 818cde2c6d..e5f58af594 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -150,7 +150,18 @@ "gasCalculators_blender_addManualLine": "Position hinzufügen", "gasCalculators_blender_lineDescription": "Bezeichnung", "gasCalculators_blender_lineAmount": "Betrag", - "gasCalculators_blender_lineNeedsDescription": "Gib eine Beschreibung oder Flasche und Mischung ein.", + "gasCalculators_blender_lineNeedsDescription": "Gib eine Bezeichnung ein.", + "gasCalculators_blender_lineKindGas": "Gasfüllung", + "gasCalculators_blender_lineKindAmount": "Freier Betrag", + "gasCalculators_blender_lineGas": "Füllung", + "gasCalculators_blender_lineStartPressure": "Anfangsdruck", + "gasCalculators_blender_lineEndPressure": "Enddruck", + "gasCalculators_blender_lineFillPressure": "Fülldruck: {pressure}", + "gasCalculators_blender_lineComputedAmount": "Betrag: {amount}", + "gasCalculators_blender_lineNoPrice": "Für dieses Gas ist kein Preis hinterlegt, es wird mit 0 berechnet.", + "gasCalculators_blender_lineInvalidPressure": "Der Enddruck muss über dem Anfangsdruck liegen.", + "gasCalculators_blender_lineNeedsCylinder": "Gib ein Flaschenvolumen ein.", + "gasCalculators_blender_lineDescriptionOptional": "Optional. Bleibt das Feld leer, wird die Bezeichnung aus der Füllung erzeugt.", "gasCalculators_blender_export": "Exportieren", "gasCalculators_blender_exportPdf": "Als PDF exportieren", "gasCalculators_blender_exportImage": "Als Bild exportieren", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 0d4e19ec61..5d2f4c54ba 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -8310,7 +8310,32 @@ "gasCalculators_blender_addManualLine": "Add a line", "gasCalculators_blender_lineDescription": "Description", "gasCalculators_blender_lineAmount": "Amount", - "gasCalculators_blender_lineNeedsDescription": "Enter a description, or a cylinder and mix.", + "gasCalculators_blender_lineNeedsDescription": "Enter a description.", + "gasCalculators_blender_lineKindGas": "Gas fill", + "gasCalculators_blender_lineKindAmount": "Free amount", + "gasCalculators_blender_lineGas": "Fill", + "gasCalculators_blender_lineStartPressure": "Start pressure", + "gasCalculators_blender_lineEndPressure": "End pressure", + "gasCalculators_blender_lineFillPressure": "Fill pressure: {pressure}", + "gasCalculators_blender_lineComputedAmount": "Amount: {amount}", + "gasCalculators_blender_lineNoPrice": "No price is set for this gas, so it is charged at 0.", + "gasCalculators_blender_lineInvalidPressure": "The end pressure must be above the start pressure.", + "gasCalculators_blender_lineNeedsCylinder": "Enter a cylinder volume.", + "gasCalculators_blender_lineDescriptionOptional": "Optional. Left empty, it is generated from the fill.", + "@gasCalculators_blender_lineFillPressure": { + "placeholders": { + "pressure": { + "type": "String" + } + } + }, + "@gasCalculators_blender_lineComputedAmount": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, "gasCalculators_blender_export": "Export", "gasCalculators_blender_exportPdf": "Export as PDF", "gasCalculators_blender_exportImage": "Export as Image", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index a9e00452b7..76f955c013 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "Añadir una línea", "gasCalculators_blender_lineDescription": "Descripción", "gasCalculators_blender_lineAmount": "Importe", - "gasCalculators_blender_lineNeedsDescription": "Introduce una descripción, o un cilindro y una mezcla.", + "gasCalculators_blender_lineNeedsDescription": "Introduce una descripción.", + "gasCalculators_blender_lineKindGas": "Llenado de gas", + "gasCalculators_blender_lineKindAmount": "Importe libre", + "gasCalculators_blender_lineGas": "Llenado", + "gasCalculators_blender_lineStartPressure": "Presión inicial", + "gasCalculators_blender_lineEndPressure": "Presión final", + "gasCalculators_blender_lineFillPressure": "Presión de llenado: {pressure}", + "gasCalculators_blender_lineComputedAmount": "Importe: {amount}", + "gasCalculators_blender_lineNoPrice": "No hay precio para este gas, así que se cobra a 0.", + "gasCalculators_blender_lineInvalidPressure": "La presión final debe ser mayor que la inicial.", + "gasCalculators_blender_lineNeedsCylinder": "Introduce un volumen de cilindro.", + "gasCalculators_blender_lineDescriptionOptional": "Opcional. Si se deja vacío, se genera a partir del llenado.", "gasCalculators_blender_export": "Exportar", "gasCalculators_blender_exportPdf": "Exportar como PDF", "gasCalculators_blender_exportImage": "Exportar como imagen", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 709f29c1eb..2d4cf3e420 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "Ajouter une ligne", "gasCalculators_blender_lineDescription": "Désignation", "gasCalculators_blender_lineAmount": "Montant", - "gasCalculators_blender_lineNeedsDescription": "Saisissez une description, ou une bouteille et un mélange.", + "gasCalculators_blender_lineNeedsDescription": "Saisissez une description.", + "gasCalculators_blender_lineKindGas": "Gonflage de gaz", + "gasCalculators_blender_lineKindAmount": "Montant libre", + "gasCalculators_blender_lineGas": "Gonflage", + "gasCalculators_blender_lineStartPressure": "Pression initiale", + "gasCalculators_blender_lineEndPressure": "Pression finale", + "gasCalculators_blender_lineFillPressure": "Pression de gonflage : {pressure}", + "gasCalculators_blender_lineComputedAmount": "Montant : {amount}", + "gasCalculators_blender_lineNoPrice": "Aucun prix n'est défini pour ce gaz, il est facturé à 0.", + "gasCalculators_blender_lineInvalidPressure": "La pression finale doit être supérieure à la pression initiale.", + "gasCalculators_blender_lineNeedsCylinder": "Saisissez un volume de bloc.", + "gasCalculators_blender_lineDescriptionOptional": "Facultatif. Laissé vide, il est généré à partir du gonflage.", "gasCalculators_blender_export": "Exporter", "gasCalculators_blender_exportPdf": "Exporter en PDF", "gasCalculators_blender_exportImage": "Exporter en image", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index cd1a44d369..66813110b7 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "הוסף שורה", "gasCalculators_blender_lineDescription": "תיאור", "gasCalculators_blender_lineAmount": "סכום", - "gasCalculators_blender_lineNeedsDescription": "יש להזין תיאור, או מכל ותערובת.", + "gasCalculators_blender_lineNeedsDescription": "יש להזין תיאור.", + "gasCalculators_blender_lineKindGas": "מילוי גז", + "gasCalculators_blender_lineKindAmount": "סכום חופשי", + "gasCalculators_blender_lineGas": "מילוי", + "gasCalculators_blender_lineStartPressure": "לחץ התחלתי", + "gasCalculators_blender_lineEndPressure": "לחץ סופי", + "gasCalculators_blender_lineFillPressure": "לחץ מילוי: {pressure}", + "gasCalculators_blender_lineComputedAmount": "סכום: {amount}", + "gasCalculators_blender_lineNoPrice": "לא הוגדר מחיר לגז זה, ולכן הוא מחויב ב-0.", + "gasCalculators_blender_lineInvalidPressure": "הלחץ הסופי חייב להיות גבוה מהלחץ ההתחלתי.", + "gasCalculators_blender_lineNeedsCylinder": "יש להזין נפח מכל.", + "gasCalculators_blender_lineDescriptionOptional": "אופציונלי. אם יישאר ריק, הוא ייווצר מתוך המילוי.", "gasCalculators_blender_export": "ייצוא", "gasCalculators_blender_exportPdf": "ייצוא כ-PDF", "gasCalculators_blender_exportImage": "ייצוא כתמונה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 541940afdb..959a8384d1 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "Tétel hozzáadása", "gasCalculators_blender_lineDescription": "Megnevezés", "gasCalculators_blender_lineAmount": "Összeg", - "gasCalculators_blender_lineNeedsDescription": "Adjon meg egy leírást, vagy egy palackot és keveréket.", + "gasCalculators_blender_lineNeedsDescription": "Adjon meg egy leírást.", + "gasCalculators_blender_lineKindGas": "Gáztöltés", + "gasCalculators_blender_lineKindAmount": "Szabad összeg", + "gasCalculators_blender_lineGas": "Töltés", + "gasCalculators_blender_lineStartPressure": "Kezdőnyomás", + "gasCalculators_blender_lineEndPressure": "Végnyomás", + "gasCalculators_blender_lineFillPressure": "Töltési nyomás: {pressure}", + "gasCalculators_blender_lineComputedAmount": "Összeg: {amount}", + "gasCalculators_blender_lineNoPrice": "Ehhez a gázhoz nincs ár megadva, ezért 0-val számolunk.", + "gasCalculators_blender_lineInvalidPressure": "A végnyomásnak nagyobbnak kell lennie a kezdőnyomásnál.", + "gasCalculators_blender_lineNeedsCylinder": "Adja meg a palack térfogatát.", + "gasCalculators_blender_lineDescriptionOptional": "Nem kötelező. Ha üresen marad, a töltésből jön létre.", "gasCalculators_blender_export": "Exportálás", "gasCalculators_blender_exportPdf": "Exportálás PDF-be", "gasCalculators_blender_exportImage": "Exportálás képként", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index ce2709136d..0e9b6b49ba 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "Aggiungi una voce", "gasCalculators_blender_lineDescription": "Descrizione", "gasCalculators_blender_lineAmount": "Importo", - "gasCalculators_blender_lineNeedsDescription": "Inserisci una descrizione, oppure una bombola e una miscela.", + "gasCalculators_blender_lineNeedsDescription": "Inserisci una descrizione.", + "gasCalculators_blender_lineKindGas": "Ricarica di gas", + "gasCalculators_blender_lineKindAmount": "Importo libero", + "gasCalculators_blender_lineGas": "Ricarica", + "gasCalculators_blender_lineStartPressure": "Pressione iniziale", + "gasCalculators_blender_lineEndPressure": "Pressione finale", + "gasCalculators_blender_lineFillPressure": "Pressione di ricarica: {pressure}", + "gasCalculators_blender_lineComputedAmount": "Importo: {amount}", + "gasCalculators_blender_lineNoPrice": "Nessun prezzo impostato per questo gas, quindi viene addebitato a 0.", + "gasCalculators_blender_lineInvalidPressure": "La pressione finale deve essere superiore a quella iniziale.", + "gasCalculators_blender_lineNeedsCylinder": "Inserisci un volume della bombola.", + "gasCalculators_blender_lineDescriptionOptional": "Facoltativo. Se lasciato vuoto, viene generato dalla ricarica.", "gasCalculators_blender_export": "Esporta", "gasCalculators_blender_exportPdf": "Esporta come PDF", "gasCalculators_blender_exportImage": "Esporta come immagine", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index b56d12a971..5536247b9e 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -23485,9 +23485,75 @@ abstract class AppLocalizations { /// No description provided for @gasCalculators_blender_lineNeedsDescription. /// /// In en, this message translates to: - /// **'Enter a description, or a cylinder and mix.'** + /// **'Enter a description.'** String get gasCalculators_blender_lineNeedsDescription; + /// No description provided for @gasCalculators_blender_lineKindGas. + /// + /// In en, this message translates to: + /// **'Gas fill'** + String get gasCalculators_blender_lineKindGas; + + /// No description provided for @gasCalculators_blender_lineKindAmount. + /// + /// In en, this message translates to: + /// **'Free amount'** + String get gasCalculators_blender_lineKindAmount; + + /// No description provided for @gasCalculators_blender_lineGas. + /// + /// In en, this message translates to: + /// **'Fill'** + String get gasCalculators_blender_lineGas; + + /// No description provided for @gasCalculators_blender_lineStartPressure. + /// + /// In en, this message translates to: + /// **'Start pressure'** + String get gasCalculators_blender_lineStartPressure; + + /// No description provided for @gasCalculators_blender_lineEndPressure. + /// + /// In en, this message translates to: + /// **'End pressure'** + String get gasCalculators_blender_lineEndPressure; + + /// No description provided for @gasCalculators_blender_lineFillPressure. + /// + /// In en, this message translates to: + /// **'Fill pressure: {pressure}'** + String gasCalculators_blender_lineFillPressure(String pressure); + + /// No description provided for @gasCalculators_blender_lineComputedAmount. + /// + /// In en, this message translates to: + /// **'Amount: {amount}'** + String gasCalculators_blender_lineComputedAmount(String amount); + + /// No description provided for @gasCalculators_blender_lineNoPrice. + /// + /// In en, this message translates to: + /// **'No price is set for this gas, so it is charged at 0.'** + String get gasCalculators_blender_lineNoPrice; + + /// No description provided for @gasCalculators_blender_lineInvalidPressure. + /// + /// In en, this message translates to: + /// **'The end pressure must be above the start pressure.'** + String get gasCalculators_blender_lineInvalidPressure; + + /// No description provided for @gasCalculators_blender_lineNeedsCylinder. + /// + /// In en, this message translates to: + /// **'Enter a cylinder volume.'** + String get gasCalculators_blender_lineNeedsCylinder; + + /// No description provided for @gasCalculators_blender_lineDescriptionOptional. + /// + /// In en, this message translates to: + /// **'Optional. Left empty, it is generated from the fill.'** + String get gasCalculators_blender_lineDescriptionOptional; + /// No description provided for @gasCalculators_blender_export. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 8610666523..9ef2cb1eee 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -14008,8 +14008,47 @@ class AppLocalizationsAr extends AppLocalizations { String get gasCalculators_blender_lineAmount => 'المبلغ'; @override - String get gasCalculators_blender_lineNeedsDescription => - 'أدخل وصفًا، أو أسطوانة ومزيجًا.'; + String get gasCalculators_blender_lineNeedsDescription => 'أدخل وصفًا.'; + + @override + String get gasCalculators_blender_lineKindGas => 'تعبئة غاز'; + + @override + String get gasCalculators_blender_lineKindAmount => 'مبلغ حر'; + + @override + String get gasCalculators_blender_lineGas => 'التعبئة'; + + @override + String get gasCalculators_blender_lineStartPressure => 'الضغط الابتدائي'; + + @override + String get gasCalculators_blender_lineEndPressure => 'الضغط النهائي'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'ضغط التعبئة: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'المبلغ: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'لم يُحدَّد سعر لهذا الغاز، لذا يُحتسب بصفر.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'يجب أن يكون الضغط النهائي أعلى من الضغط الابتدائي.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => 'أدخل سعة الأسطوانة.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'اختياري. إذا تُرك فارغًا، يُنشأ من التعبئة.'; @override String get gasCalculators_blender_export => 'تصدير'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index cdecf4164b..514735d3e2 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -14227,7 +14227,48 @@ class AppLocalizationsDe extends AppLocalizations { @override String get gasCalculators_blender_lineNeedsDescription => - 'Gib eine Beschreibung oder Flasche und Mischung ein.'; + 'Gib eine Bezeichnung ein.'; + + @override + String get gasCalculators_blender_lineKindGas => 'Gasfüllung'; + + @override + String get gasCalculators_blender_lineKindAmount => 'Freier Betrag'; + + @override + String get gasCalculators_blender_lineGas => 'Füllung'; + + @override + String get gasCalculators_blender_lineStartPressure => 'Anfangsdruck'; + + @override + String get gasCalculators_blender_lineEndPressure => 'Enddruck'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'Fülldruck: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'Betrag: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'Für dieses Gas ist kein Preis hinterlegt, es wird mit 0 berechnet.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'Der Enddruck muss über dem Anfangsdruck liegen.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => + 'Gib ein Flaschenvolumen ein.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'Optional. Bleibt das Feld leer, wird die Bezeichnung aus der Füllung erzeugt.'; @override String get gasCalculators_blender_export => 'Exportieren'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 455999785e..2b45b09052 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -14019,7 +14019,48 @@ class AppLocalizationsEn extends AppLocalizations { @override String get gasCalculators_blender_lineNeedsDescription => - 'Enter a description, or a cylinder and mix.'; + 'Enter a description.'; + + @override + String get gasCalculators_blender_lineKindGas => 'Gas fill'; + + @override + String get gasCalculators_blender_lineKindAmount => 'Free amount'; + + @override + String get gasCalculators_blender_lineGas => 'Fill'; + + @override + String get gasCalculators_blender_lineStartPressure => 'Start pressure'; + + @override + String get gasCalculators_blender_lineEndPressure => 'End pressure'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'Fill pressure: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'Amount: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'No price is set for this gas, so it is charged at 0.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'The end pressure must be above the start pressure.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => + 'Enter a cylinder volume.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'Optional. Left empty, it is generated from the fill.'; @override String get gasCalculators_blender_export => 'Export'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index c8f726fd09..4bc134e858 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -14233,7 +14233,48 @@ class AppLocalizationsEs extends AppLocalizations { @override String get gasCalculators_blender_lineNeedsDescription => - 'Introduce una descripción, o un cilindro y una mezcla.'; + 'Introduce una descripción.'; + + @override + String get gasCalculators_blender_lineKindGas => 'Llenado de gas'; + + @override + String get gasCalculators_blender_lineKindAmount => 'Importe libre'; + + @override + String get gasCalculators_blender_lineGas => 'Llenado'; + + @override + String get gasCalculators_blender_lineStartPressure => 'Presión inicial'; + + @override + String get gasCalculators_blender_lineEndPressure => 'Presión final'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'Presión de llenado: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'Importe: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'No hay precio para este gas, así que se cobra a 0.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'La presión final debe ser mayor que la inicial.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => + 'Introduce un volumen de cilindro.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'Opcional. Si se deja vacío, se genera a partir del llenado.'; @override String get gasCalculators_blender_export => 'Exportar'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index a518dfb797..2298e74654 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -14293,7 +14293,48 @@ class AppLocalizationsFr extends AppLocalizations { @override String get gasCalculators_blender_lineNeedsDescription => - 'Saisissez une description, ou une bouteille et un mélange.'; + 'Saisissez une description.'; + + @override + String get gasCalculators_blender_lineKindGas => 'Gonflage de gaz'; + + @override + String get gasCalculators_blender_lineKindAmount => 'Montant libre'; + + @override + String get gasCalculators_blender_lineGas => 'Gonflage'; + + @override + String get gasCalculators_blender_lineStartPressure => 'Pression initiale'; + + @override + String get gasCalculators_blender_lineEndPressure => 'Pression finale'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'Pression de gonflage : $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'Montant : $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'Aucun prix n\'est défini pour ce gaz, il est facturé à 0.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'La pression finale doit être supérieure à la pression initiale.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => + 'Saisissez un volume de bloc.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'Facultatif. Laissé vide, il est généré à partir du gonflage.'; @override String get gasCalculators_blender_export => 'Exporter'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 6a4149b432..afd00976fb 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -13909,8 +13909,47 @@ class AppLocalizationsHe extends AppLocalizations { String get gasCalculators_blender_lineAmount => 'סכום'; @override - String get gasCalculators_blender_lineNeedsDescription => - 'יש להזין תיאור, או מכל ותערובת.'; + String get gasCalculators_blender_lineNeedsDescription => 'יש להזין תיאור.'; + + @override + String get gasCalculators_blender_lineKindGas => 'מילוי גז'; + + @override + String get gasCalculators_blender_lineKindAmount => 'סכום חופשי'; + + @override + String get gasCalculators_blender_lineGas => 'מילוי'; + + @override + String get gasCalculators_blender_lineStartPressure => 'לחץ התחלתי'; + + @override + String get gasCalculators_blender_lineEndPressure => 'לחץ סופי'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'לחץ מילוי: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'סכום: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'לא הוגדר מחיר לגז זה, ולכן הוא מחויב ב-0.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'הלחץ הסופי חייב להיות גבוה מהלחץ ההתחלתי.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => 'יש להזין נפח מכל.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'אופציונלי. אם יישאר ריק, הוא ייווצר מתוך המילוי.'; @override String get gasCalculators_blender_export => 'ייצוא'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index affd8734a5..ffaf63118e 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -14201,7 +14201,48 @@ class AppLocalizationsHu extends AppLocalizations { @override String get gasCalculators_blender_lineNeedsDescription => - 'Adjon meg egy leírást, vagy egy palackot és keveréket.'; + 'Adjon meg egy leírást.'; + + @override + String get gasCalculators_blender_lineKindGas => 'Gáztöltés'; + + @override + String get gasCalculators_blender_lineKindAmount => 'Szabad összeg'; + + @override + String get gasCalculators_blender_lineGas => 'Töltés'; + + @override + String get gasCalculators_blender_lineStartPressure => 'Kezdőnyomás'; + + @override + String get gasCalculators_blender_lineEndPressure => 'Végnyomás'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'Töltési nyomás: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'Összeg: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'Ehhez a gázhoz nincs ár megadva, ezért 0-val számolunk.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'A végnyomásnak nagyobbnak kell lennie a kezdőnyomásnál.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => + 'Adja meg a palack térfogatát.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'Nem kötelező. Ha üresen marad, a töltésből jön létre.'; @override String get gasCalculators_blender_export => 'Exportálás'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 64b177e8f7..4b4e4b819d 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -14248,7 +14248,48 @@ class AppLocalizationsIt extends AppLocalizations { @override String get gasCalculators_blender_lineNeedsDescription => - 'Inserisci una descrizione, oppure una bombola e una miscela.'; + 'Inserisci una descrizione.'; + + @override + String get gasCalculators_blender_lineKindGas => 'Ricarica di gas'; + + @override + String get gasCalculators_blender_lineKindAmount => 'Importo libero'; + + @override + String get gasCalculators_blender_lineGas => 'Ricarica'; + + @override + String get gasCalculators_blender_lineStartPressure => 'Pressione iniziale'; + + @override + String get gasCalculators_blender_lineEndPressure => 'Pressione finale'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'Pressione di ricarica: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'Importo: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'Nessun prezzo impostato per questo gas, quindi viene addebitato a 0.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'La pressione finale deve essere superiore a quella iniziale.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => + 'Inserisci un volume della bombola.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'Facoltativo. Se lasciato vuoto, viene generato dalla ricarica.'; @override String get gasCalculators_blender_export => 'Esporta'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 7005f70da5..30b23dc220 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -14138,7 +14138,48 @@ class AppLocalizationsNl extends AppLocalizations { @override String get gasCalculators_blender_lineNeedsDescription => - 'Voer een omschrijving in, of een fles en mengsel.'; + 'Voer een omschrijving in.'; + + @override + String get gasCalculators_blender_lineKindGas => 'Gasvulling'; + + @override + String get gasCalculators_blender_lineKindAmount => 'Vrij bedrag'; + + @override + String get gasCalculators_blender_lineGas => 'Vulling'; + + @override + String get gasCalculators_blender_lineStartPressure => 'Begindruk'; + + @override + String get gasCalculators_blender_lineEndPressure => 'Einddruk'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'Vuldruk: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'Bedrag: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'Voor dit gas is geen prijs ingesteld, dus het wordt tegen 0 berekend.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'De einddruk moet boven de begindruk liggen.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => + 'Voer een flesinhoud in.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'Optioneel. Leeg gelaten wordt het uit de vulling gemaakt.'; @override String get gasCalculators_blender_export => 'Exporteren'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 97f34c1974..fb5deca14e 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -14245,7 +14245,48 @@ class AppLocalizationsPt extends AppLocalizations { @override String get gasCalculators_blender_lineNeedsDescription => - 'Introduza uma descrição, ou um cilindro e uma mistura.'; + 'Introduza uma descrição.'; + + @override + String get gasCalculators_blender_lineKindGas => 'Enchimento de gás'; + + @override + String get gasCalculators_blender_lineKindAmount => 'Valor livre'; + + @override + String get gasCalculators_blender_lineGas => 'Enchimento'; + + @override + String get gasCalculators_blender_lineStartPressure => 'Pressão inicial'; + + @override + String get gasCalculators_blender_lineEndPressure => 'Pressão final'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return 'Pressão de enchimento: $pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return 'Valor: $amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => + 'Não há preço definido para este gás, por isso é cobrado a 0.'; + + @override + String get gasCalculators_blender_lineInvalidPressure => + 'A pressão final tem de ser superior à pressão inicial.'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => + 'Introduza um volume de cilindro.'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + 'Opcional. Se ficar vazio, é gerado a partir do enchimento.'; @override String get gasCalculators_blender_export => 'Exportar'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 80b6de6611..0043978fbb 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -13567,7 +13567,45 @@ class AppLocalizationsZh extends AppLocalizations { String get gasCalculators_blender_lineAmount => '金额'; @override - String get gasCalculators_blender_lineNeedsDescription => '请输入说明,或气瓶与混合气。'; + String get gasCalculators_blender_lineNeedsDescription => '请输入说明。'; + + @override + String get gasCalculators_blender_lineKindGas => '气体充填'; + + @override + String get gasCalculators_blender_lineKindAmount => '自由金额'; + + @override + String get gasCalculators_blender_lineGas => '充填'; + + @override + String get gasCalculators_blender_lineStartPressure => '初始压力'; + + @override + String get gasCalculators_blender_lineEndPressure => '最终压力'; + + @override + String gasCalculators_blender_lineFillPressure(String pressure) { + return '充填压力:$pressure'; + } + + @override + String gasCalculators_blender_lineComputedAmount(String amount) { + return '金额:$amount'; + } + + @override + String get gasCalculators_blender_lineNoPrice => '此气体未设置价格,按 0 计费。'; + + @override + String get gasCalculators_blender_lineInvalidPressure => '最终压力必须高于初始压力。'; + + @override + String get gasCalculators_blender_lineNeedsCylinder => '请输入气瓶容积。'; + + @override + String get gasCalculators_blender_lineDescriptionOptional => + '可选。留空时根据充填自动生成。'; @override String get gasCalculators_blender_export => '导出'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 8c362fe224..4e788c7e29 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "Regel toevoegen", "gasCalculators_blender_lineDescription": "Omschrijving", "gasCalculators_blender_lineAmount": "Bedrag", - "gasCalculators_blender_lineNeedsDescription": "Voer een omschrijving in, of een fles en mengsel.", + "gasCalculators_blender_lineNeedsDescription": "Voer een omschrijving in.", + "gasCalculators_blender_lineKindGas": "Gasvulling", + "gasCalculators_blender_lineKindAmount": "Vrij bedrag", + "gasCalculators_blender_lineGas": "Vulling", + "gasCalculators_blender_lineStartPressure": "Begindruk", + "gasCalculators_blender_lineEndPressure": "Einddruk", + "gasCalculators_blender_lineFillPressure": "Vuldruk: {pressure}", + "gasCalculators_blender_lineComputedAmount": "Bedrag: {amount}", + "gasCalculators_blender_lineNoPrice": "Voor dit gas is geen prijs ingesteld, dus het wordt tegen 0 berekend.", + "gasCalculators_blender_lineInvalidPressure": "De einddruk moet boven de begindruk liggen.", + "gasCalculators_blender_lineNeedsCylinder": "Voer een flesinhoud in.", + "gasCalculators_blender_lineDescriptionOptional": "Optioneel. Leeg gelaten wordt het uit de vulling gemaakt.", "gasCalculators_blender_export": "Exporteren", "gasCalculators_blender_exportPdf": "Exporteren als PDF", "gasCalculators_blender_exportImage": "Exporteren als afbeelding", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 1bc61ad99b..8959e676e5 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "Adicionar uma linha", "gasCalculators_blender_lineDescription": "Descrição", "gasCalculators_blender_lineAmount": "Valor", - "gasCalculators_blender_lineNeedsDescription": "Introduza uma descrição, ou um cilindro e uma mistura.", + "gasCalculators_blender_lineNeedsDescription": "Introduza uma descrição.", + "gasCalculators_blender_lineKindGas": "Enchimento de gás", + "gasCalculators_blender_lineKindAmount": "Valor livre", + "gasCalculators_blender_lineGas": "Enchimento", + "gasCalculators_blender_lineStartPressure": "Pressão inicial", + "gasCalculators_blender_lineEndPressure": "Pressão final", + "gasCalculators_blender_lineFillPressure": "Pressão de enchimento: {pressure}", + "gasCalculators_blender_lineComputedAmount": "Valor: {amount}", + "gasCalculators_blender_lineNoPrice": "Não há preço definido para este gás, por isso é cobrado a 0.", + "gasCalculators_blender_lineInvalidPressure": "A pressão final tem de ser superior à pressão inicial.", + "gasCalculators_blender_lineNeedsCylinder": "Introduza um volume de cilindro.", + "gasCalculators_blender_lineDescriptionOptional": "Opcional. Se ficar vazio, é gerado a partir do enchimento.", "gasCalculators_blender_export": "Exportar", "gasCalculators_blender_exportPdf": "Exportar como PDF", "gasCalculators_blender_exportImage": "Exportar como imagem", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 186a45f2f7..2462d75157 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -142,7 +142,18 @@ "gasCalculators_blender_addManualLine": "添加条目", "gasCalculators_blender_lineDescription": "说明", "gasCalculators_blender_lineAmount": "金额", - "gasCalculators_blender_lineNeedsDescription": "请输入说明,或气瓶与混合气。", + "gasCalculators_blender_lineNeedsDescription": "请输入说明。", + "gasCalculators_blender_lineKindGas": "气体充填", + "gasCalculators_blender_lineKindAmount": "自由金额", + "gasCalculators_blender_lineGas": "充填", + "gasCalculators_blender_lineStartPressure": "初始压力", + "gasCalculators_blender_lineEndPressure": "最终压力", + "gasCalculators_blender_lineFillPressure": "充填压力:{pressure}", + "gasCalculators_blender_lineComputedAmount": "金额:{amount}", + "gasCalculators_blender_lineNoPrice": "此气体未设置价格,按 0 计费。", + "gasCalculators_blender_lineInvalidPressure": "最终压力必须高于初始压力。", + "gasCalculators_blender_lineNeedsCylinder": "请输入气瓶容积。", + "gasCalculators_blender_lineDescriptionOptional": "可选。留空时根据充填自动生成。", "gasCalculators_blender_export": "导出", "gasCalculators_blender_exportPdf": "导出为 PDF", "gasCalculators_blender_exportImage": "导出为图片", diff --git a/test/features/gas_calculators/blender_invoice_test.dart b/test/features/gas_calculators/blender_invoice_test.dart index 09c2ab6195..670963850f 100644 --- a/test/features/gas_calculators/blender_invoice_test.dart +++ b/test/features/gas_calculators/blender_invoice_test.dart @@ -7,10 +7,13 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:share_plus_platform_interface/share_plus_platform_interface.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart' show GasMix; import 'package:submersion/features/gas_calculators/domain/blending/billed_fill.dart'; +import 'package:submersion/features/gas_calculators/domain/blending/blender_gas_role.dart'; import 'package:submersion/features/gas_calculators/domain/blending/blender_preferences.dart'; import 'package:submersion/features/gas_calculators/domain/blending/flush_fee.dart'; import 'package:submersion/features/gas_calculators/presentation/providers/gas_blender_providers.dart'; @@ -18,6 +21,7 @@ import 'package:submersion/features/gas_calculators/presentation/widgets/blender import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_invoice_card.dart'; import 'package:submersion/features/gas_calculators/presentation/widgets/blender/blender_invoice_export_sheet.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -50,18 +54,40 @@ class _TestSettingsNotifier extends StateNotifier dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -Future _pump(WidgetTester tester) async { +/// Opens "Add a line", switched to the free-amount kind when [freeAmount]. +Future _openAddLine( + WidgetTester tester, { + bool freeAmount = false, +}) async { + await tester.tap(find.byKey(const Key('blender-add-manual-line'))); + await tester.pumpAndSettle(); + if (freeAmount) { + await tester.tap(find.text('Free amount')); + await tester.pumpAndSettle(); + } +} + +/// Picks [gas] from the gas fill's dropdown. +Future _pickGas(WidgetTester tester, String gas) async { + await tester.tap(find.byKey(const Key('blender-line-gas'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(gas).last); + await tester.pumpAndSettle(); +} + +Future _pump( + WidgetTester tester, { + List presets = const [], + AppSettings settings = const AppSettings(defaultCurrency: 'CHF'), +}) async { await tester.binding.setSurfaceSize(const Size(900, 2400)); addTearDown(() => tester.binding.setSurfaceSize(null)); late WidgetRef captured; await tester.pumpWidget( ProviderScope( overrides: [ - settingsProvider.overrideWith( - (ref) => - _TestSettingsNotifier(const AppSettings(defaultCurrency: 'CHF')), - ), - tankPresetsProvider.overrideWith((ref) async => const []), + settingsProvider.overrideWith((ref) => _TestSettingsNotifier(settings)), + tankPresetsProvider.overrideWith((ref) async => presets), ], child: MaterialApp( locale: const Locale('en'), @@ -149,41 +175,89 @@ void main() { expect(fill.isManual, isTrue); }); - test('a custom mix round-trips through JSON alongside the fill', () { + test('a hand-entered gas fill round-trips its role and start pressure ' + '(#2302)', () { const fill = BilledFill( id: 'c', - label: 'Tx 21/35', - lines: [], - total: 40, - customMix: BilledCustomMix(cylinderLiters: 11.1, o2: 21, he: 35), + label: 'Helium', + lines: [ + BilledGasLine( + gas: 'Helium', + addedBar: 150, + cost: 27, + freeGasLiters: 1800, + cylinderLiters: 12, + role: BlenderGasRole.he, + startBar: 50, + ), + ], + total: 27, ); final decoded = BilledFill.fromJson( jsonDecode(jsonEncode(fill.toJson())) as Map, )!; - expect(decoded.customMix, isNotNull); - expect(decoded.customMix!.cylinderLiters, 11.1); - expect(decoded.customMix!.o2, 21); - expect(decoded.customMix!.he, 35); + final line = decoded.manualGasLine; + expect(line, isNotNull); + expect(line!.role, BlenderGasRole.he); + expect(line.startBar, 50); + expect(line.endBar, 200); + expect(decoded.isManual, isFalse); }); - test('a fill without a custom mix decodes with none', () { - const fill = BilledFill(id: 'd', label: 'x', lines: [], total: 1); - final decoded = BilledFill.fromJson( - jsonDecode(jsonEncode(fill.toJson())) as Map, - )!; - expect(decoded.customMix, isNull); + test('a computed fill is not a hand-entered gas fill', () { + const fill = BilledFill( + id: 'd', + label: 'Tx 18/45', + lines: [BilledGasLine(gas: 'O₂', addedBar: 10, cost: 10)], + total: 10, + ); + expect(fill.manualGasLine, isNull); + }); + + test('an unknown role in a synced blob decodes to none, keeping the ' + 'line', () { + final line = BilledGasLine.fromJson({ + 'gas': 'X', + 'addedBar': 10, + 'role': 'argon', + 'startBar': 'oops', + })!; + expect(line.role, isNull); + expect(line.startBar, isNull); + expect(line.addedBar, 10); + }); + + test('a manual line saved with the retired custom mix still decodes, ' + 'without it (#2302)', () { + final decoded = BilledFill.fromJson({ + 'id': 'e', + 'label': 'Tx 21/35', + 'lines': [], + 'total': 40, + 'customMix': {'cylinderLiters': 11.1, 'o2': 21, 'he': 35}, + })!; + expect(decoded.label, 'Tx 21/35'); + expect(decoded.total, 40); + expect(decoded.isManual, isTrue); + expect(decoded.toJson().containsKey('customMix'), isFalse); }); - test('copyWith can clear a custom mix', () { + test('copyWith can replace the lines, and leaves them alone otherwise', () { const fill = BilledFill( - id: 'e', - label: 'Tx 21/35', - lines: [], - total: 40, - customMix: BilledCustomMix(cylinderLiters: 11.1, o2: 21, he: 35), + id: 'f', + label: 'Helium', + lines: [ + BilledGasLine( + gas: 'Helium', + addedBar: 150, + cost: 27, + role: BlenderGasRole.he, + ), + ], + total: 27, ); - expect(fill.copyWith(label: 'x').customMix, isNotNull); - expect(fill.copyWith(clearCustomMix: true).customMix, isNull); + expect(fill.copyWith(label: 'x').lines, hasLength(1)); + expect(fill.copyWith(lines: const []).isManual, isTrue); }); test('an unpriced line makes the total incomplete, not smaller', () { @@ -373,10 +447,9 @@ void main() { expect(ref.read(blenderBilledFillsProvider), isEmpty); }); - testWidgets('a manual line can be added', (tester) async { + testWidgets('a free-amount line can be added', (tester) async { final ref = await _pump(tester); - await tester.tap(find.byKey(const Key('blender-add-manual-line'))); - await tester.pumpAndSettle(); + await _openAddLine(tester, freeAmount: true); await tester.enterText( find.byKey(const Key('blender-line-description')), @@ -396,18 +469,13 @@ void main() { expect(fills.single.total, closeTo(12.50, 0.001)); }); - testWidgets('saving a line with nothing to name it says so', ( + testWidgets('a free amount with nothing to name it says so', ( tester, ) async { - // PR #1359 review: with no description and no usable mix there was - // nothing to label the line with, and Save simply returned - no line, - // no message, a button that reads as broken. - await _pump(tester); - await tester.tap(find.byKey(const Key('blender-add-manual-line'))); - await tester.pumpAndSettle(); - // The mix fields are pre-filled, so the label can fall back to them - // until one of them is cleared. - await tester.enterText(find.widgetWithText(TextField, 'O\u2082 (%)'), ''); + // PR #1359 review: with nothing to label the line with, Save simply + // returned - no line, no message, a button that reads as broken. + final ref = await _pump(tester); + await _openAddLine(tester, freeAmount: true); await tester.enterText( find.byKey(const Key('blender-line-amount')), '12.50', @@ -420,83 +488,270 @@ void main() { expect(find.textContaining('Enter a description'), findsOneWidget); // Still open, with the amount intact, so the diver can fix it in place. expect(find.byKey(const Key('blender-line-amount')), findsOneWidget); + expect(ref.read(blenderBilledFillsProvider), isEmpty); }); - testWidgets('a custom mix line records the cylinder and gas entered', ( - tester, - ) async { - final ref = await _pump(tester); - await tester.tap(find.byKey(const Key('blender-add-manual-line'))); + testWidgets('the kind switch shows only the fields that kind needs ' + '(#2302)', (tester) async { + await _pump(tester); + await _openAddLine(tester); + + // A new line starts as a gas fill. + expect(find.byKey(const Key('blender-line-gas')), findsOneWidget); + expect(find.byKey(const Key('blender-line-cylinder')), findsOneWidget); + expect( + find.byKey(const Key('blender-line-start-pressure')), + findsOneWidget, + ); + expect( + find.byKey(const Key('blender-line-end-pressure')), + findsOneWidget, + ); + expect( + find.byKey(const Key('blender-line-computed-amount')), + findsOneWidget, + ); + expect(find.byKey(const Key('blender-line-amount')), findsNothing); + + await tester.tap(find.text('Free amount')); await tester.pumpAndSettle(); + expect(find.byKey(const Key('blender-line-amount')), findsOneWidget); + expect(find.byKey(const Key('blender-line-gas')), findsNothing); + expect(find.byKey(const Key('blender-line-cylinder')), findsNothing); + expect(find.byKey(const Key('blender-line-end-pressure')), findsNothing); + expect( + find.byKey(const Key('blender-line-computed-amount')), + findsNothing, + ); + }); + + testWidgets('a gas fill is priced from the cylinder, the pressures and ' + 'the gas price (#2302)', (tester) async { + final ref = await _pump(tester); + ref.read(blenderGasPricesProvider.notifier).state = const [1.0, 1.5, 0.1]; + await _openAddLine(tester); + + await _pickGas(tester, 'Helium'); await tester.enterText( find.byKey(const Key('blender-line-cylinder')), - '11.1', + '12', ); await tester.enterText( - find.byKey(const Key('blender-line-amount')), - '30', + find.byKey(const Key('blender-line-start-pressure')), + '50', + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '200', ); - // Description left blank on purpose: the label falls back to the mix. - await tester.enterText(find.widgetWithText(TextField, 'O₂ (%)'), '21'); - await tester.enterText(find.widgetWithText(TextField, 'He (%)'), '35'); await tester.pumpAndSettle(); + + // Shown live, as text rather than an editable field. + expect( + tester + .widget(find.byKey(const Key('blender-line-fill-pressure'))) + .data, + contains('150'), + ); + expect( + tester + .widget(find.byKey(const Key('blender-line-computed-amount'))) + .data, + contains('27.00'), + ); + expect(find.byKey(const Key('blender-line-no-price')), findsNothing); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); await tester.pumpAndSettle(); final fills = ref.read(blenderBilledFillsProvider); expect(fills, hasLength(1)); - expect(fills.single.label, 'Tx 21/35'); - expect(fills.single.customMix, isNotNull); - expect(fills.single.customMix!.cylinderLiters, closeTo(11.1, 0.001)); - expect(fills.single.customMix!.o2, 21); - expect(fills.single.customMix!.he, 35); + expect(fills.single.total, closeTo(27, 1e-9)); + final line = fills.single.manualGasLine!; + expect(line.role, BlenderGasRole.he); + expect(line.gas, 'Helium'); + expect(line.addedBar, 150); + expect(line.startBar, 50); + expect(line.freeGasLiters, 1800); + expect(line.cylinderLiters, 12); + // No description typed: the label is generated from the fill. + expect(fills.single.label, startsWith('Helium · 12')); + expect(fills.single.label, endsWith('150 bar')); }); - testWidgets('re-editing a manual line pre-fills its saved mix', ( - tester, - ) async { + testWidgets('a gas without a price is charged at 0, and the form says ' + 'so (#2302)', (tester) async { final ref = await _pump(tester); + await _openAddLine(tester); + + expect(find.byKey(const Key('blender-line-no-price')), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final fills = ref.read(blenderBilledFillsProvider); + expect(fills, hasLength(1)); + expect(fills.single.total, 0); + // Priced at zero, so the bill is complete rather than flagged. + expect(totalOf(fills).complete, isTrue); + }); + + testWidgets('an end pressure not above the start pressure blocks saving ' + '(#2302)', (tester) async { + final ref = await _pump(tester); + await _openAddLine(tester); + + await tester.enterText( + find.byKey(const Key('blender-line-start-pressure')), + '200', + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '100', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(find.textContaining('end pressure must be above'), findsOneWidget); + expect(ref.read(blenderBilledFillsProvider), isEmpty); + }); + + testWidgets('a cylinder preset sets the volume and the end pressure, ' + 'which stays editable (#2302)', (tester) async { + final ref = await _pump( + tester, + presets: [ + TankPresetEntity( + id: 'd12', + name: 'd12', + displayName: 'D12 232', + volumeLiters: 12, + workingPressureBar: 232, + material: TankMaterial.steel, + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + ), + ], + ); + ref.read(blenderGasPricesProvider.notifier).state = const [1.0, 1.5, 0.1]; + await _openAddLine(tester); + + await tester.tap(find.byKey(const Key('blender-line-cylinder-presets'))); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('D12 232').last); + await tester.pumpAndSettle(); + + String text(String key) => + tester.widget(find.byKey(Key(key))).controller!.text; + expect(text('blender-line-cylinder'), '12'); + expect(text('blender-line-end-pressure'), '232'); + + // Overridden by hand, e.g. for the oxygen step of a blend. + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '30', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final line = ref.read(blenderBilledFillsProvider).single.manualGasLine!; + expect(line.role, BlenderGasRole.o2); + expect(line.addedBar, 30); + expect(line.cylinderLiters, 12); + }); + + testWidgets('re-editing a gas fill reopens it with its values and ' + 'reprices it (#2302)', (tester) async { + final ref = await _pump(tester); + ref.read(blenderGasPricesProvider.notifier).state = const [1.0, 1.5, 0.1]; ref.read(blenderBilledFillsProvider.notifier).state = const [ BilledFill( id: 'a', - label: 'Tx 21/35', - lines: [], - total: 30, - customMix: BilledCustomMix(cylinderLiters: 11.1, o2: 21, he: 35), + label: 'Twinset', + lines: [ + BilledGasLine( + gas: 'Helium', + addedBar: 150, + cost: 27, + freeGasLiters: 1800, + cylinderLiters: 12, + role: BlenderGasRole.he, + startBar: 50, + ), + ], + total: 27, ), ]; await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Actions for Tx 21/35')); + await tester.tap(find.byTooltip('Actions for Twinset')); await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Tx 21/35')); + await tester.tap(find.text('Edit Twinset')); await tester.pumpAndSettle(); - final o2Field = tester.widget( - find.widgetWithText(TextField, 'O₂ (%)'), - ); - expect(o2Field.controller!.text, '21'); - final heField = tester.widget( - find.widgetWithText(TextField, 'He (%)'), - ); - expect(heField.controller!.text, '35'); + String text(String key) => + tester.widget(find.byKey(Key(key))).controller!.text; + expect(text('blender-line-description'), 'Twinset'); + expect(text('blender-line-cylinder'), '12'); + expect(text('blender-line-start-pressure'), '50'); + expect(text('blender-line-end-pressure'), '200'); - // Changing the mix and saving updates the stored fill rather than - // adding a second one. - await tester.enterText(find.widgetWithText(TextField, 'He (%)'), '45'); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '150', + ); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Save')); await tester.pumpAndSettle(); final fills = ref.read(blenderBilledFillsProvider); expect(fills, hasLength(1)); - expect(fills.single.customMix!.he, 45); + expect(fills.single.label, 'Twinset'); + expect(fills.single.manualGasLine!.addedBar, 100); + expect(fills.single.total, closeTo(18, 1e-9)); }); - testWidgets('a computed fill offers no mix fields when re-edited', ( - tester, - ) async { + testWidgets('pressures are entered in the diver\'s unit and stored in ' + 'bar (#2302)', (tester) async { + final ref = await _pump( + tester, + settings: const AppSettings( + defaultCurrency: 'CHF', + pressureUnit: PressureUnit.psi, + ), + ); + ref.read(blenderGasPricesProvider.notifier).state = const [1.0, 1.5, 0.1]; + await _openAddLine(tester); + + expect(find.textContaining('Start pressure (psi)'), findsOneWidget); + await tester.enterText( + find.byKey(const Key('blender-line-cylinder')), + '10', + ); + await tester.enterText( + find.byKey(const Key('blender-line-start-pressure')), + '0', + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '3000', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final fill = ref.read(blenderBilledFillsProvider).single; + const bar = 3000 / 14.5038; + expect(fill.manualGasLine!.addedBar, closeTo(bar, 0.01)); + expect(fill.total, closeTo(10 * bar / 100, 0.01)); + expect(fill.label, endsWith('psi')); + }); + + testWidgets('a computed fill offers neither the kind switch nor the gas ' + 'fields when re-edited', (tester) async { final ref = await _pump(tester); ref.read(blenderBilledFillsProvider.notifier).state = const [ BilledFill( @@ -513,8 +768,18 @@ void main() { await tester.tap(find.text('Edit Tx 18/45')); await tester.pumpAndSettle(); + expect(find.byKey(const Key('blender-line-kind')), findsNothing); expect(find.byKey(const Key('blender-line-cylinder')), findsNothing); - expect(find.widgetWithText(TextField, 'O₂ (%)'), findsNothing); + expect(find.byKey(const Key('blender-line-amount')), findsOneWidget); + + // Saving keeps the itemisation it was computed with. + await tester.enterText(find.byKey(const Key('blender-line-amount')), '9'); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + final fill = ref.read(blenderBilledFillsProvider).single; + expect(fill.lines, hasLength(1)); + expect(fill.total, 9); }); testWidgets('paying asks first, then archives and empties the bill', ( @@ -765,8 +1030,7 @@ void main() { /// on screen, then opens the export picker. Future addLineAndOpenPicker(WidgetTester tester) async { await _pump(tester); - await tester.tap(find.byKey(const Key('blender-add-manual-line'))); - await tester.pumpAndSettle(); + await _openAddLine(tester, freeAmount: true); await tester.enterText( find.byKey(const Key('blender-line-description')), 'Analyser cell', From 0d6eefea71e6309302b31e4249cfcc75c541dc42 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:05:20 +0200 Subject: [PATCH 03/10] fix(blender): keep a reopened gas fill's price and exact inputs An untouched gas fill keeps the amount and gas name it was billed with when only its description changes, pressures reopen with two decimals, a cubic-foot preset bills its exact water volume, switching a gas fill to a free amount starts from its cost, and an empty pressure gets its own message. Refs #2302 --- .../domain/blending/billed_fill.dart | 3 + .../blender/blender_line_edit_sheet.dart | 113 +++++++++--- lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 1 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 4 + lib/l10n/arb/app_localizations_de.dart | 4 + lib/l10n/arb/app_localizations_en.dart | 4 + lib/l10n/arb/app_localizations_es.dart | 4 + lib/l10n/arb/app_localizations_fr.dart | 4 + lib/l10n/arb/app_localizations_he.dart | 4 + lib/l10n/arb/app_localizations_hu.dart | 4 + lib/l10n/arb/app_localizations_it.dart | 4 + lib/l10n/arb/app_localizations_nl.dart | 4 + lib/l10n/arb/app_localizations_pt.dart | 4 + lib/l10n/arb/app_localizations_zh.dart | 3 + lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + .../gas_calculators/blender_invoice_test.dart | 161 ++++++++++++++++++ 26 files changed, 316 insertions(+), 21 deletions(-) diff --git a/lib/features/gas_calculators/domain/blending/billed_fill.dart b/lib/features/gas_calculators/domain/blending/billed_fill.dart index 30276c8cd7..c076ace2e3 100644 --- a/lib/features/gas_calculators/domain/blending/billed_fill.dart +++ b/lib/features/gas_calculators/domain/blending/billed_fill.dart @@ -41,6 +41,9 @@ class BilledGasLine { /// The fill gas a hand-entered gas fill was billed for (issue #2302). /// Null on every line the blender computed: set only by "Add a line", so /// editing that line can reopen the form on the same gas. + /// + /// [BilledFill.manualGasLine] tells a hand-entered fill apart by this field + /// alone, so a computed line must keep leaving it null. final BlenderGasRole? role; /// The pressure a hand-entered gas fill started from, in bar. The end diff --git a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart index 0c93c80490..19b4d66d8f 100644 --- a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart @@ -76,6 +76,17 @@ class _BlenderLineEditSheetState extends ConsumerState { late BlenderLineKind _kind; late BlenderGasRole _role; + /// The exact water volume of the cylinder preset last picked, until the + /// field is typed into. The field shows it rounded, which in cubic feet + /// loses enough to misprice the fill if it were read back from the text. + double? _presetLiters; + + /// The gas fill fields as the sheet opened them, to tell an edit of the + /// description alone from a changed fill. + late final String _seedCylinder; + late final String _seedStart; + late final String _seedEnd; + String? _error; /// Only a new line or one entered by hand may pick its kind. A fill the @@ -112,28 +123,44 @@ class _BlenderLineEditSheetState extends ConsumerState { _label = TextEditingController( text: fill == null || fill.label == generated ? '' : fill.label, ); + // Seeded for a gas fill too: switching it to a free amount starts from + // what it cost rather than from a blank that would leave it unpriced. _amount = TextEditingController( - text: fill?.total == null || gasLine != null - ? '' - : formatRoundedForInput(fill!.total!, 2), + text: fill?.total == null ? '' : formatRoundedForInput(fill!.total!, 2), ); final double cylinderLiters = gasLine?.cylinderLiters ?? ref.read(blenderCylinderLitersProvider); - _cylinder = TextEditingController( - text: formatRoundedForInput( - litersToDisplayVolume(cylinderLiters, settings), - 2, - ), + _seedCylinder = formatRoundedForInput( + litersToDisplayVolume(cylinderLiters, settings), + 2, ); + _cylinder = TextEditingController(text: _seedCylinder); final double startBar = gasLine?.startBar ?? 0; final double endBar = gasLine?.endBar ?? ref.read(blenderTargetPressureProvider); - _startPressure = TextEditingController( - text: formatRoundedForInput(units.convertPressure(startBar), 0), - ); - _endPressure = TextEditingController( - text: formatRoundedForInput(units.convertPressure(endBar), 0), - ); + // Two decimals rather than whole numbers, so reopening a fill and saving + // it untouched cannot move its pressures. + _seedStart = formatRoundedForInput(units.convertPressure(startBar), 2); + _seedEnd = formatRoundedForInput(units.convertPressure(endBar), 2); + _startPressure = TextEditingController(text: _seedStart); + _endPressure = TextEditingController(text: _seedEnd); + } + + /// The saved gas fill being edited, when none of its fill fields has + /// changed. It then keeps the amount and gas name it was billed with: a + /// price or topup gas changed since must not reprice a line reopened only + /// to fix its description. + BilledGasLine? get _unchangedGasLine { + final line = widget.fill?.manualGasLine; + if (line == null || line.role != _role || _presetLiters != null) { + return null; + } + if (_cylinder.text != _seedCylinder || + _startPressure.text != _seedStart || + _endPressure.text != _seedEnd) { + return null; + } + return line; } @override @@ -176,6 +203,7 @@ class _BlenderLineEditSheetState extends ConsumerState { role.index < prices.length ? prices[role.index] : null; double? _cylinderLiters(AppSettings settings) { + if (_presetLiters != null) return _presetLiters; final shown = parseUserDecimal(_cylinder.text); if (shown == null || shown <= 0) return null; return displayVolumeToLiters(shown, settings); @@ -221,6 +249,25 @@ class _BlenderLineEditSheetState extends ConsumerState { final settings = ref.read(settingsProvider); final units = UnitFormatter(settings); + final unchanged = _unchangedGasLine; + if (unchanged != null) { + final fill = widget.fill!; + Navigator.of(context).pop( + BlenderLineEdit( + label: label.isNotEmpty + ? label + : _generatedLabel( + unchanged.gas, + unchanged.cylinderLiters, + unchanged.addedBar, + units, + ), + amount: fill.total, + lines: [unchanged], + ), + ); + return; + } final liters = _cylinderLiters(settings); if (liters == null) { setState( @@ -228,6 +275,13 @@ class _BlenderLineEditSheetState extends ConsumerState { ); return; } + if (parseUserDecimal(_startPressure.text) == null || + parseUserDecimal(_endPressure.text) == null) { + setState( + () => _error = context.l10n.gasCalculators_blender_lineNeedsPressure, + ); + return; + } final cost = _gasCost(settings, units, ref.read(blenderGasPricesProvider)); if (cost == null) { setState( @@ -372,8 +426,13 @@ class _BlenderLineEditSheetState extends ConsumerState { final prices = ref.watch(blenderGasPricesProvider); final topupO2 = ref.watch(blenderTopupO2PercentProvider); final currency = ref.watch(blenderCurrencyProvider); - final cost = _gasCost(settings, units, prices); - final unpriced = _priceFor(_role, prices) == null; + // An untouched saved fill shows what it was billed at, the same amount + // saving it will keep. + final unchanged = _unchangedGasLine; + final cost = unchanged == null ? _gasCost(settings, units, prices) : null; + final addedBar = unchanged?.addedBar ?? cost?.addedBar; + final amount = unchanged != null ? widget.fill!.total : cost?.cost; + final unpriced = unchanged == null && _priceFor(_role, prices) == null; final resultStyle = theme.textTheme.titleSmall; return [ DropdownButtonFormField( @@ -423,7 +482,7 @@ class _BlenderLineEditSheetState extends ConsumerState { const SizedBox(height: 12), Text( context.l10n.gasCalculators_blender_lineFillPressure( - cost == null ? '--' : units.formatPressure(cost.addedBar), + addedBar == null ? '--' : units.formatPressure(addedBar), ), key: const Key('blender-line-fill-pressure'), style: resultStyle, @@ -431,7 +490,7 @@ class _BlenderLineEditSheetState extends ConsumerState { const SizedBox(height: 4), Text( context.l10n.gasCalculators_blender_lineComputedAmount( - cost == null ? '--' : formatMoney(cost.cost, currency), + amount == null ? '--' : formatMoney(amount, currency), ), key: const Key('blender-line-computed-amount'), style: resultStyle?.copyWith(fontWeight: FontWeight.w700), @@ -468,6 +527,8 @@ class _BlenderLineEditSheetState extends ConsumerState { _cylinder, '${context.l10n.gasCalculators_blender_cylinderVolume} ' '(${units.volumeSymbol})', + // Typing a size replaces the preset's exact one. + onChanged: () => _presetLiters = null, ), ), const SizedBox(width: 8), @@ -503,14 +564,16 @@ class _BlenderLineEditSheetState extends ConsumerState { // what the cylinder is filled to (issue #2302). Still editable // afterwards, since only the last gas of a blend reaches it. onSelected: (preset) => setState(() { + _presetLiters = preset.volumeLiters; _cylinder.text = formatRoundedForInput( litersToDisplayVolume(preset.volumeLiters, settings), 2, ); _endPressure.text = formatRoundedForInput( units.convertPressure(preset.workingPressureBar), - 0, + 2, ); + _error = null; }), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), @@ -528,13 +591,21 @@ class _BlenderLineEditSheetState extends ConsumerState { ); } - Widget _numberField(Key key, TextEditingController controller, String label) { + Widget _numberField( + Key key, + TextEditingController controller, + String label, { + VoidCallback? onChanged, + }) { return TextField( key: key, controller: controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], - onChanged: (_) => setState(() => _error = null), + onChanged: (_) => setState(() { + onChanged?.call(); + _error = null; + }), decoration: InputDecoration( labelText: label, isDense: true, diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2381faa521..95ccae8d45 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "المبلغ: {amount}", "gasCalculators_blender_lineNoPrice": "لم يُحدَّد سعر لهذا الغاز، لذا يُحتسب بصفر.", "gasCalculators_blender_lineInvalidPressure": "يجب أن يكون الضغط النهائي أعلى من الضغط الابتدائي.", + "gasCalculators_blender_lineNeedsPressure": "أدخل ضغطًا ابتدائيًا وضغطًا نهائيًا.", "gasCalculators_blender_lineNeedsCylinder": "أدخل سعة الأسطوانة.", "gasCalculators_blender_lineDescriptionOptional": "اختياري. إذا تُرك فارغًا، يُنشأ من التعبئة.", "gasCalculators_blender_export": "تصدير", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index e5f58af594..f668395938 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -160,6 +160,7 @@ "gasCalculators_blender_lineComputedAmount": "Betrag: {amount}", "gasCalculators_blender_lineNoPrice": "Für dieses Gas ist kein Preis hinterlegt, es wird mit 0 berechnet.", "gasCalculators_blender_lineInvalidPressure": "Der Enddruck muss über dem Anfangsdruck liegen.", + "gasCalculators_blender_lineNeedsPressure": "Gib einen Anfangs- und einen Enddruck ein.", "gasCalculators_blender_lineNeedsCylinder": "Gib ein Flaschenvolumen ein.", "gasCalculators_blender_lineDescriptionOptional": "Optional. Bleibt das Feld leer, wird die Bezeichnung aus der Füllung erzeugt.", "gasCalculators_blender_export": "Exportieren", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5d2f4c54ba..cf96e29094 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -8320,6 +8320,7 @@ "gasCalculators_blender_lineComputedAmount": "Amount: {amount}", "gasCalculators_blender_lineNoPrice": "No price is set for this gas, so it is charged at 0.", "gasCalculators_blender_lineInvalidPressure": "The end pressure must be above the start pressure.", + "gasCalculators_blender_lineNeedsPressure": "Enter a start and an end pressure.", "gasCalculators_blender_lineNeedsCylinder": "Enter a cylinder volume.", "gasCalculators_blender_lineDescriptionOptional": "Optional. Left empty, it is generated from the fill.", "@gasCalculators_blender_lineFillPressure": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 76f955c013..2953f5baf4 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "Importe: {amount}", "gasCalculators_blender_lineNoPrice": "No hay precio para este gas, así que se cobra a 0.", "gasCalculators_blender_lineInvalidPressure": "La presión final debe ser mayor que la inicial.", + "gasCalculators_blender_lineNeedsPressure": "Introduce una presión inicial y una final.", "gasCalculators_blender_lineNeedsCylinder": "Introduce un volumen de cilindro.", "gasCalculators_blender_lineDescriptionOptional": "Opcional. Si se deja vacío, se genera a partir del llenado.", "gasCalculators_blender_export": "Exportar", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 2d4cf3e420..0bc9660c81 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "Montant : {amount}", "gasCalculators_blender_lineNoPrice": "Aucun prix n'est défini pour ce gaz, il est facturé à 0.", "gasCalculators_blender_lineInvalidPressure": "La pression finale doit être supérieure à la pression initiale.", + "gasCalculators_blender_lineNeedsPressure": "Saisissez une pression initiale et une pression finale.", "gasCalculators_blender_lineNeedsCylinder": "Saisissez un volume de bloc.", "gasCalculators_blender_lineDescriptionOptional": "Facultatif. Laissé vide, il est généré à partir du gonflage.", "gasCalculators_blender_export": "Exporter", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 66813110b7..688204c8f6 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "סכום: {amount}", "gasCalculators_blender_lineNoPrice": "לא הוגדר מחיר לגז זה, ולכן הוא מחויב ב-0.", "gasCalculators_blender_lineInvalidPressure": "הלחץ הסופי חייב להיות גבוה מהלחץ ההתחלתי.", + "gasCalculators_blender_lineNeedsPressure": "יש להזין לחץ התחלתי ולחץ סופי.", "gasCalculators_blender_lineNeedsCylinder": "יש להזין נפח מכל.", "gasCalculators_blender_lineDescriptionOptional": "אופציונלי. אם יישאר ריק, הוא ייווצר מתוך המילוי.", "gasCalculators_blender_export": "ייצוא", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 959a8384d1..2b24aa0aed 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "Összeg: {amount}", "gasCalculators_blender_lineNoPrice": "Ehhez a gázhoz nincs ár megadva, ezért 0-val számolunk.", "gasCalculators_blender_lineInvalidPressure": "A végnyomásnak nagyobbnak kell lennie a kezdőnyomásnál.", + "gasCalculators_blender_lineNeedsPressure": "Adjon meg egy kezdő- és egy végnyomást.", "gasCalculators_blender_lineNeedsCylinder": "Adja meg a palack térfogatát.", "gasCalculators_blender_lineDescriptionOptional": "Nem kötelező. Ha üresen marad, a töltésből jön létre.", "gasCalculators_blender_export": "Exportálás", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 0e9b6b49ba..7a9b6da2c8 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "Importo: {amount}", "gasCalculators_blender_lineNoPrice": "Nessun prezzo impostato per questo gas, quindi viene addebitato a 0.", "gasCalculators_blender_lineInvalidPressure": "La pressione finale deve essere superiore a quella iniziale.", + "gasCalculators_blender_lineNeedsPressure": "Inserisci una pressione iniziale e una finale.", "gasCalculators_blender_lineNeedsCylinder": "Inserisci un volume della bombola.", "gasCalculators_blender_lineDescriptionOptional": "Facoltativo. Se lasciato vuoto, viene generato dalla ricarica.", "gasCalculators_blender_export": "Esporta", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5536247b9e..bde573063e 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -23542,6 +23542,12 @@ abstract class AppLocalizations { /// **'The end pressure must be above the start pressure.'** String get gasCalculators_blender_lineInvalidPressure; + /// No description provided for @gasCalculators_blender_lineNeedsPressure. + /// + /// In en, this message translates to: + /// **'Enter a start and an end pressure.'** + String get gasCalculators_blender_lineNeedsPressure; + /// No description provided for @gasCalculators_blender_lineNeedsCylinder. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 9ef2cb1eee..235df60583 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -14043,6 +14043,10 @@ class AppLocalizationsAr extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'يجب أن يكون الضغط النهائي أعلى من الضغط الابتدائي.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'أدخل ضغطًا ابتدائيًا وضغطًا نهائيًا.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'أدخل سعة الأسطوانة.'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 514735d3e2..204c7bef55 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -14262,6 +14262,10 @@ class AppLocalizationsDe extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'Der Enddruck muss über dem Anfangsdruck liegen.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'Gib einen Anfangs- und einen Enddruck ein.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'Gib ein Flaschenvolumen ein.'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 2b45b09052..bdc7cf46c2 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -14054,6 +14054,10 @@ class AppLocalizationsEn extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'The end pressure must be above the start pressure.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'Enter a start and an end pressure.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'Enter a cylinder volume.'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 4bc134e858..ab19c9e633 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -14268,6 +14268,10 @@ class AppLocalizationsEs extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'La presión final debe ser mayor que la inicial.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'Introduce una presión inicial y una final.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'Introduce un volumen de cilindro.'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 2298e74654..a534301796 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -14328,6 +14328,10 @@ class AppLocalizationsFr extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'La pression finale doit être supérieure à la pression initiale.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'Saisissez une pression initiale et une pression finale.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'Saisissez un volume de bloc.'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index afd00976fb..f4e0c06340 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -13944,6 +13944,10 @@ class AppLocalizationsHe extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'הלחץ הסופי חייב להיות גבוה מהלחץ ההתחלתי.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'יש להזין לחץ התחלתי ולחץ סופי.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'יש להזין נפח מכל.'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index ffaf63118e..fd95e9550d 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -14236,6 +14236,10 @@ class AppLocalizationsHu extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'A végnyomásnak nagyobbnak kell lennie a kezdőnyomásnál.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'Adjon meg egy kezdő- és egy végnyomást.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'Adja meg a palack térfogatát.'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 4b4e4b819d..eaf1b5dcf2 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -14283,6 +14283,10 @@ class AppLocalizationsIt extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'La pressione finale deve essere superiore a quella iniziale.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'Inserisci una pressione iniziale e una finale.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'Inserisci un volume della bombola.'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 30b23dc220..05617cb8d6 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -14173,6 +14173,10 @@ class AppLocalizationsNl extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'De einddruk moet boven de begindruk liggen.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'Voer een begin- en een einddruk in.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'Voer een flesinhoud in.'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index fb5deca14e..51f5f78114 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -14280,6 +14280,10 @@ class AppLocalizationsPt extends AppLocalizations { String get gasCalculators_blender_lineInvalidPressure => 'A pressão final tem de ser superior à pressão inicial.'; + @override + String get gasCalculators_blender_lineNeedsPressure => + 'Introduza uma pressão inicial e uma final.'; + @override String get gasCalculators_blender_lineNeedsCylinder => 'Introduza um volume de cilindro.'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 0043978fbb..df713bf06a 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -13600,6 +13600,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get gasCalculators_blender_lineInvalidPressure => '最终压力必须高于初始压力。'; + @override + String get gasCalculators_blender_lineNeedsPressure => '请输入初始压力和最终压力。'; + @override String get gasCalculators_blender_lineNeedsCylinder => '请输入气瓶容积。'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 4e788c7e29..cc13d46e84 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "Bedrag: {amount}", "gasCalculators_blender_lineNoPrice": "Voor dit gas is geen prijs ingesteld, dus het wordt tegen 0 berekend.", "gasCalculators_blender_lineInvalidPressure": "De einddruk moet boven de begindruk liggen.", + "gasCalculators_blender_lineNeedsPressure": "Voer een begin- en een einddruk in.", "gasCalculators_blender_lineNeedsCylinder": "Voer een flesinhoud in.", "gasCalculators_blender_lineDescriptionOptional": "Optioneel. Leeg gelaten wordt het uit de vulling gemaakt.", "gasCalculators_blender_export": "Exporteren", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 8959e676e5..5b38c47aeb 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "Valor: {amount}", "gasCalculators_blender_lineNoPrice": "Não há preço definido para este gás, por isso é cobrado a 0.", "gasCalculators_blender_lineInvalidPressure": "A pressão final tem de ser superior à pressão inicial.", + "gasCalculators_blender_lineNeedsPressure": "Introduza uma pressão inicial e uma final.", "gasCalculators_blender_lineNeedsCylinder": "Introduza um volume de cilindro.", "gasCalculators_blender_lineDescriptionOptional": "Opcional. Se ficar vazio, é gerado a partir do enchimento.", "gasCalculators_blender_export": "Exportar", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 2462d75157..81f6e73a69 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -152,6 +152,7 @@ "gasCalculators_blender_lineComputedAmount": "金额:{amount}", "gasCalculators_blender_lineNoPrice": "此气体未设置价格,按 0 计费。", "gasCalculators_blender_lineInvalidPressure": "最终压力必须高于初始压力。", + "gasCalculators_blender_lineNeedsPressure": "请输入初始压力和最终压力。", "gasCalculators_blender_lineNeedsCylinder": "请输入气瓶容积。", "gasCalculators_blender_lineDescriptionOptional": "可选。留空时根据充填自动生成。", "gasCalculators_blender_export": "导出", diff --git a/test/features/gas_calculators/blender_invoice_test.dart b/test/features/gas_calculators/blender_invoice_test.dart index 670963850f..ca0e7cb6e8 100644 --- a/test/features/gas_calculators/blender_invoice_test.dart +++ b/test/features/gas_calculators/blender_invoice_test.dart @@ -750,6 +750,167 @@ void main() { expect(fill.label, endsWith('psi')); }); + testWidgets('reopening a gas fill to change only its description keeps ' + 'what it was billed at (#2302 review)', (tester) async { + final ref = await _pump(tester); + // Priced since at a rate that would make this line 41.85. + ref.read(blenderGasPricesProvider.notifier).state = const [1.0, 1.5, 0.1]; + ref.read(blenderBilledFillsProvider.notifier).state = const [ + BilledFill( + id: 'a', + label: 'Twinset', + lines: [ + BilledGasLine( + gas: 'Helium', + addedBar: 232.5, + cost: 27, + freeGasLiters: 2790, + cylinderLiters: 12, + role: BlenderGasRole.he, + startBar: 0, + ), + ], + total: 27, + ), + ]; + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Actions for Twinset')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Twinset')); + await tester.pumpAndSettle(); + + // Not rounded to a whole number, so an untouched save cannot move it. + expect( + tester + .widget( + find.byKey(const Key('blender-line-end-pressure')), + ) + .controller! + .text, + '232.5', + ); + expect( + tester + .widget(find.byKey(const Key('blender-line-computed-amount'))) + .data, + contains('27.00'), + ); + + await tester.enterText( + find.byKey(const Key('blender-line-description')), + 'Doubles', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final fill = ref.read(blenderBilledFillsProvider).single; + expect(fill.label, 'Doubles'); + expect(fill.total, 27); + expect(fill.manualGasLine!.addedBar, 232.5); + expect(fill.manualGasLine!.cost, 27); + }); + + testWidgets('switching a gas fill to a free amount starts from what it ' + 'cost (#2302 review)', (tester) async { + final ref = await _pump(tester); + ref.read(blenderBilledFillsProvider.notifier).state = const [ + BilledFill( + id: 'a', + label: 'Twinset', + lines: [ + BilledGasLine( + gas: 'Helium', + addedBar: 150, + cost: 27, + cylinderLiters: 12, + role: BlenderGasRole.he, + startBar: 50, + ), + ], + total: 27, + ), + ]; + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Actions for Twinset')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Twinset')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Free amount')); + await tester.pumpAndSettle(); + + expect( + tester + .widget(find.byKey(const Key('blender-line-amount'))) + .controller! + .text, + '27', + ); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final fill = ref.read(blenderBilledFillsProvider).single; + expect(fill.isManual, isTrue); + expect(fill.total, 27); + }); + + testWidgets('an empty pressure asks for one rather than blaming the ' + 'order (#2302 review)', (tester) async { + final ref = await _pump(tester); + await _openAddLine(tester); + + await tester.enterText( + find.byKey(const Key('blender-line-start-pressure')), + '', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Enter a start and an end pressure'), + findsOneWidget, + ); + expect(ref.read(blenderBilledFillsProvider), isEmpty); + }); + + testWidgets('a preset picked in cubic feet bills its exact water volume ' + '(#2302 review)', (tester) async { + final ref = await _pump( + tester, + settings: const AppSettings( + defaultCurrency: 'CHF', + volumeUnit: VolumeUnit.cubicFeet, + ), + presets: [ + TankPresetEntity( + id: 'al80', + name: 'al80', + displayName: 'AL80', + volumeLiters: 11.1, + workingPressureBar: 207, + material: TankMaterial.aluminum, + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + ), + ], + ); + await _openAddLine(tester); + + await tester.tap(find.byKey(const Key('blender-line-cylinder-presets'))); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('AL80').last); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final line = ref.read(blenderBilledFillsProvider).single.manualGasLine!; + expect(line.cylinderLiters, 11.1); + expect(line.addedBar, 207); + }); + testWidgets('a computed fill offers neither the kind switch nor the gas ' 'fields when re-edited', (tester) async { final ref = await _pump(tester); From 69e4b55d0661225202ca060e907d95f8a403fbff Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:32:08 +0200 Subject: [PATCH 04/10] fix(blender): recognise a generated gas fill label in any unit A label generated before the diver switched units is still regenerated when the fill changes, and switching a gas fill with a generated label to a free amount carries that label over. Refs #2302 --- .../blender/blender_line_edit_sheet.dart | 51 ++++++++-- .../gas_calculators/blender_invoice_test.dart | 93 +++++++++++++++++++ 2 files changed, 134 insertions(+), 10 deletions(-) diff --git a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart index 19b4d66d8f..a0236a9b7e 100644 --- a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/utils/number_input.dart'; @@ -111,17 +112,12 @@ class _BlenderLineEditSheetState extends ConsumerState { _role = gasLine?.role ?? BlenderGasRole.o2; // A label this sheet generated is left blank rather than kept as typed - // text, so changing the pressure or the gas regenerates it. - final generated = gasLine == null - ? null - : _generatedLabel( - gasLine.gas, - gasLine.cylinderLiters, - gasLine.addedBar, - units, - ); + // text, so changing the pressure or the gas regenerates it. Checked in + // every unit combination: the diver may have switched units since. + final generated = + gasLine != null && _isGeneratedLabel(fill!.label, gasLine, settings); _label = TextEditingController( - text: fill == null || fill.label == generated ? '' : fill.label, + text: fill == null || generated ? '' : fill.label, ); // Seeded for a gas fill too: switching it to a free amount starts from // what it cost rather than from a blank that would leave it unpriced. @@ -173,6 +169,32 @@ class _BlenderLineEditSheetState extends ConsumerState { super.dispose(); } + /// Whether [label] is what [_generatedLabel] made for [line], in any + /// pressure and volume unit. + bool _isGeneratedLabel( + String label, + BilledGasLine line, + AppSettings settings, + ) { + for (final pressure in PressureUnit.values) { + for (final volume in VolumeUnit.values) { + final units = UnitFormatter( + settings.copyWith(pressureUnit: pressure, volumeUnit: volume), + ); + if (label == + _generatedLabel( + line.gas, + line.cylinderLiters, + line.addedBar, + units, + )) { + return true; + } + } + } + return false; + } + String _generatedLabel( String gasName, double? cylinderLiters, @@ -360,6 +382,15 @@ class _BlenderLineEditSheetState extends ConsumerState { onSelectionChanged: (selection) => setState(() { _kind = selection.single; _error = null; + // A free amount needs a description, and a generated one + // was left blank: carry the line's label over rather than + // make the diver retype it. + final fill = widget.fill; + if (_kind == BlenderLineKind.amount && + _label.text.trim().isEmpty && + fill != null) { + _label.text = fill.label; + } }), ), const SizedBox(height: 12), diff --git a/test/features/gas_calculators/blender_invoice_test.dart b/test/features/gas_calculators/blender_invoice_test.dart index ca0e7cb6e8..3f96662075 100644 --- a/test/features/gas_calculators/blender_invoice_test.dart +++ b/test/features/gas_calculators/blender_invoice_test.dart @@ -911,6 +911,99 @@ void main() { expect(line.addedBar, 207); }); + testWidgets('a generated label saved in bar still regenerates once the ' + 'diver has switched to psi (#2302 review)', (tester) async { + final ref = await _pump( + tester, + settings: const AppSettings( + defaultCurrency: 'CHF', + pressureUnit: PressureUnit.psi, + ), + ); + ref.read(blenderBilledFillsProvider.notifier).state = const [ + BilledFill( + id: 'a', + label: 'Helium · 12 L · 150 bar', + lines: [ + BilledGasLine( + gas: 'Helium', + addedBar: 150, + cost: 0, + cylinderLiters: 12, + role: BlenderGasRole.he, + startBar: 0, + ), + ], + total: 0, + ), + ]; + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150 bar')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Helium · 12 L · 150 bar')); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.byKey(const Key('blender-line-description')), + ) + .controller! + .text, + isEmpty, + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '1000', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect( + ref.read(blenderBilledFillsProvider).single.label, + endsWith('1000 psi'), + ); + }); + + testWidgets('switching a gas fill with a generated label to a free ' + 'amount keeps its label (#2302 review)', (tester) async { + final ref = await _pump(tester); + ref.read(blenderBilledFillsProvider.notifier).state = const [ + BilledFill( + id: 'a', + label: 'Helium · 12 L · 150 bar', + lines: [ + BilledGasLine( + gas: 'Helium', + addedBar: 150, + cost: 27, + cylinderLiters: 12, + role: BlenderGasRole.he, + startBar: 0, + ), + ], + total: 27, + ), + ]; + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150 bar')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Helium · 12 L · 150 bar')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Free amount')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final fill = ref.read(blenderBilledFillsProvider).single; + expect(fill.label, 'Helium · 12 L · 150 bar'); + expect(fill.isManual, isTrue); + expect(fill.total, 27); + }); + testWidgets('a computed fill offers neither the kind switch nor the gas ' 'fields when re-edited', (tester) async { final ref = await _pump(tester); From 84f7c9928fe24d91e59070c1893672808d088736 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:06:49 +0200 Subject: [PATCH 05/10] fix(blender): reject a non-finite gas price instead of charging 0 Refs #2302 --- .../domain/blending/blend_billing.dart | 5 ++++- .../domain/blend_billing_test.dart | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/features/gas_calculators/domain/blending/blend_billing.dart b/lib/features/gas_calculators/domain/blending/blend_billing.dart index 6d5d4f1392..f5e17f850e 100644 --- a/lib/features/gas_calculators/domain/blending/blend_billing.dart +++ b/lib/features/gas_calculators/domain/blending/blend_billing.dart @@ -149,10 +149,13 @@ ManualGasFillCost? manualGasFillCost({ if (!waterLiters.isFinite || !startBar.isFinite || !endBar.isFinite) { return null; } + // A corrupt tariff is not an unset one: charging it as 0 would pass a + // broken price off as a deliberate free fill. + if (pricePer100 != null && !pricePer100.isFinite) return null; if (waterLiters <= 0 || startBar < 0 || endBar <= startBar) return null; final addedBar = endBar - startBar; final liters = waterLiters * addedBar; - final price = pricePer100 != null && pricePer100.isFinite ? pricePer100 : 0; + final price = pricePer100 ?? 0; return ManualGasFillCost( addedBar: addedBar, freeGasLiters: liters, diff --git a/test/features/gas_calculators/domain/blend_billing_test.dart b/test/features/gas_calculators/domain/blend_billing_test.dart index fb629f1333..7b51056f4f 100644 --- a/test/features/gas_calculators/domain/blend_billing_test.dart +++ b/test/features/gas_calculators/domain/blend_billing_test.dart @@ -256,6 +256,27 @@ void main() { ); }); + test('a non-finite price is rejected, not treated as unset', () { + expect( + manualGasFillCost( + waterLiters: 12, + startBar: 0, + endBar: 100, + pricePer100: double.nan, + ), + isNull, + ); + expect( + manualGasFillCost( + waterLiters: 12, + startBar: 0, + endBar: 100, + pricePer100: double.infinity, + ), + isNull, + ); + }); + test('non-finite input is rejected rather than priced', () { expect( manualGasFillCost( From 1bf4650cef9dad4d51715dfbbf961b6c4446f947 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:36:19 +0200 Subject: [PATCH 06/10] fix(blender): keep a saved gas fill's exact volume and label on edit An untouched cylinder field reuses the saved fill's exact volume instead of reparsing its rounded cubic-foot text, and an untouched fill keeps its generated label as saved after a unit change. Refs #2302 --- .../blender/blender_line_edit_sheet.dart | 16 ++-- .../gas_calculators/blender_invoice_test.dart | 86 +++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart index a0236a9b7e..6b9a663ee0 100644 --- a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart @@ -226,6 +226,10 @@ class _BlenderLineEditSheetState extends ConsumerState { double? _cylinderLiters(AppSettings settings) { if (_presetLiters != null) return _presetLiters; + // An untouched field still holds the saved fill's exact volume, which + // its rounded text would lose in cubic feet. + final saved = widget.fill?.manualGasLine?.cylinderLiters; + if (saved != null && _cylinder.text == _seedCylinder) return saved; final shown = parseUserDecimal(_cylinder.text); if (shown == null || shown <= 0) return null; return displayVolumeToLiters(shown, settings); @@ -276,14 +280,10 @@ class _BlenderLineEditSheetState extends ConsumerState { final fill = widget.fill!; Navigator.of(context).pop( BlenderLineEdit( - label: label.isNotEmpty - ? label - : _generatedLabel( - unchanged.gas, - unchanged.cylinderLiters, - unchanged.addedBar, - units, - ), + // A blank description here means the saved one was generated, so + // it is kept as saved: the fill did not change, and regenerating + // it would only restate it in whatever unit is active now. + label: label.isNotEmpty ? label : fill.label, amount: fill.total, lines: [unchanged], ), diff --git a/test/features/gas_calculators/blender_invoice_test.dart b/test/features/gas_calculators/blender_invoice_test.dart index 3f96662075..8590ab5862 100644 --- a/test/features/gas_calculators/blender_invoice_test.dart +++ b/test/features/gas_calculators/blender_invoice_test.dart @@ -1004,6 +1004,92 @@ void main() { expect(fill.total, 27); }); + testWidgets('editing only the pressure of a gas fill saved in cubic feet ' + 'keeps its exact volume (#2302 review)', (tester) async { + final ref = await _pump( + tester, + settings: const AppSettings( + defaultCurrency: 'CHF', + volumeUnit: VolumeUnit.cubicFeet, + ), + ); + ref.read(blenderBilledFillsProvider.notifier).state = const [ + BilledFill( + id: 'a', + label: 'AL80', + lines: [ + BilledGasLine( + gas: 'O₂', + addedBar: 100, + cost: 0, + cylinderLiters: 11.1, + role: BlenderGasRole.o2, + startBar: 0, + ), + ], + total: 0, + ), + ]; + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Actions for AL80')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit AL80')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '150', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final line = ref.read(blenderBilledFillsProvider).single.manualGasLine!; + expect(line.addedBar, 150); + expect(line.cylinderLiters, 11.1); + expect(line.freeGasLiters, closeTo(1665, 1e-9)); + }); + + testWidgets('saving an untouched gas fill after a unit change keeps its ' + 'generated label as saved (#2302 review)', (tester) async { + final ref = await _pump( + tester, + settings: const AppSettings( + defaultCurrency: 'CHF', + pressureUnit: PressureUnit.psi, + ), + ); + ref.read(blenderBilledFillsProvider.notifier).state = const [ + BilledFill( + id: 'a', + label: 'Helium · 12 L · 150 bar', + lines: [ + BilledGasLine( + gas: 'Helium', + addedBar: 150, + cost: 27, + cylinderLiters: 12, + role: BlenderGasRole.he, + startBar: 0, + ), + ], + total: 27, + ), + ]; + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150 bar')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Helium · 12 L · 150 bar')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final fill = ref.read(blenderBilledFillsProvider).single; + expect(fill.label, 'Helium · 12 L · 150 bar'); + expect(fill.total, 27); + }); + testWidgets('a computed fill offers neither the kind switch nor the gas ' 'fields when re-edited', (tester) async { final ref = await _pump(tester); From 137b84e194187248cc6fc7cf61112b79e5f59330 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:15:57 +0200 Subject: [PATCH 07/10] fix(blender): reject a gas fill whose price overflows to infinity Refs #2302 --- .../domain/blending/blend_billing.dart | 6 ++++- .../domain/blend_billing_test.dart | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/features/gas_calculators/domain/blending/blend_billing.dart b/lib/features/gas_calculators/domain/blending/blend_billing.dart index f5e17f850e..51bdb38efd 100644 --- a/lib/features/gas_calculators/domain/blending/blend_billing.dart +++ b/lib/features/gas_calculators/domain/blending/blend_billing.dart @@ -156,9 +156,13 @@ ManualGasFillCost? manualGasFillCost({ final addedBar = endBar - startBar; final liters = waterLiters * addedBar; final price = pricePer100 ?? 0; + final cost = liters / 100 * price; + // Finite inputs can still multiply out to infinity, which a saved bill + // could not encode as JSON. + if (!liters.isFinite || !cost.isFinite) return null; return ManualGasFillCost( addedBar: addedBar, freeGasLiters: liters, - cost: liters / 100 * price, + cost: cost, ); } diff --git a/test/features/gas_calculators/domain/blend_billing_test.dart b/test/features/gas_calculators/domain/blend_billing_test.dart index 7b51056f4f..42acd48f79 100644 --- a/test/features/gas_calculators/domain/blend_billing_test.dart +++ b/test/features/gas_calculators/domain/blend_billing_test.dart @@ -277,6 +277,29 @@ void main() { ); }); + test('finite input that overflows to infinity is rejected', () { + // The volume itself overflows. + expect( + manualGasFillCost( + waterLiters: 1e300, + startBar: 0, + endBar: 1e10, + pricePer100: 1, + ), + isNull, + ); + // The volume is finite, only the cost overflows. + expect( + manualGasFillCost( + waterLiters: 1e300, + startBar: 0, + endBar: 1, + pricePer100: 1e20, + ), + isNull, + ); + }); + test('non-finite input is rejected rather than priced', () { expect( manualGasFillCost( From 66797826b86ebe97a530fdf4d34936f5d50a3c80 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:04:43 +0200 Subject: [PATCH 08/10] fix(blender): read the line form's numbers with the blender's smart parser A dot typed under a comma-decimal locale is read as the decimal separator in every field of the line form, the same correction the blender's other fields already apply. Refs #2302 --- .../blender/blender_line_edit_sheet.dart | 14 ++--- .../gas_calculators/blender_invoice_test.dart | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart index 6b9a663ee0..08d8a792f2 100644 --- a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart @@ -230,7 +230,7 @@ class _BlenderLineEditSheetState extends ConsumerState { // its rounded text would lose in cubic feet. final saved = widget.fill?.manualGasLine?.cylinderLiters; if (saved != null && _cylinder.text == _seedCylinder) return saved; - final shown = parseUserDecimal(_cylinder.text); + final shown = smartParseUserDecimal(_cylinder.text); if (shown == null || shown <= 0) return null; return displayVolumeToLiters(shown, settings); } @@ -242,8 +242,8 @@ class _BlenderLineEditSheetState extends ConsumerState { List prices, ) { final liters = _cylinderLiters(settings); - final start = parseUserDecimal(_startPressure.text); - final end = parseUserDecimal(_endPressure.text); + final start = smartParseUserDecimal(_startPressure.text); + final end = smartParseUserDecimal(_endPressure.text); if (liters == null || start == null || end == null) return null; return manualGasFillCost( waterLiters: liters, @@ -266,7 +266,7 @@ class _BlenderLineEditSheetState extends ConsumerState { Navigator.of(context).pop( BlenderLineEdit( label: label, - amount: parseUserDecimal(_amount.text), + amount: smartParseUserDecimal(_amount.text), lines: _kindEditable ? const [] : null, ), ); @@ -297,8 +297,8 @@ class _BlenderLineEditSheetState extends ConsumerState { ); return; } - if (parseUserDecimal(_startPressure.text) == null || - parseUserDecimal(_endPressure.text) == null) { + if (smartParseUserDecimal(_startPressure.text) == null || + smartParseUserDecimal(_endPressure.text) == null) { setState( () => _error = context.l10n.gasCalculators_blender_lineNeedsPressure, ); @@ -313,7 +313,7 @@ class _BlenderLineEditSheetState extends ConsumerState { } final gasName = _gasName(_role); final startBar = units.pressureToBar( - parseUserDecimal(_startPressure.text)!, + smartParseUserDecimal(_startPressure.text)!, ); Navigator.of(context).pop( BlenderLineEdit( diff --git a/test/features/gas_calculators/blender_invoice_test.dart b/test/features/gas_calculators/blender_invoice_test.dart index 8590ab5862..b6df3f58ca 100644 --- a/test/features/gas_calculators/blender_invoice_test.dart +++ b/test/features/gas_calculators/blender_invoice_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:excel_community/excel_community.dart' as xl; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:share_plus_platform_interface/share_plus_platform_interface.dart'; @@ -1090,6 +1091,57 @@ void main() { expect(fill.total, 27); }); + testWidgets('a dot typed under a German locale is read as the decimal ' + 'separator in every field of the line form (#2302 review)', ( + tester, + ) async { + // Under de, '.' is the grouping separator, but "12.5" cannot be a + // well-formed grouping, so it unambiguously means 12,5 -- the same + // correction the blender's other fields apply. + final previousLocale = Intl.defaultLocale; + Intl.defaultLocale = 'de'; + addTearDown(() => Intl.defaultLocale = previousLocale); + final ref = await _pump(tester); + ref.read(blenderGasPricesProvider.notifier).state = const [1.0, 1.5, 0.1]; + + await _openAddLine(tester); + await tester.enterText( + find.byKey(const Key('blender-line-cylinder')), + '12.5', + ); + await tester.enterText( + find.byKey(const Key('blender-line-start-pressure')), + '0.5', + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '200.5', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final line = ref.read(blenderBilledFillsProvider).single.manualGasLine!; + expect(line.cylinderLiters, 12.5); + expect(line.startBar, 0.5); + expect(line.addedBar, 200); + + await _openAddLine(tester, freeAmount: true); + await tester.enterText( + find.byKey(const Key('blender-line-description')), + 'Analyser cell', + ); + await tester.enterText( + find.byKey(const Key('blender-line-amount')), + '12.5', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(ref.read(blenderBilledFillsProvider).last.total, 12.5); + }); + testWidgets('a computed fill offers neither the kind switch nor the gas ' 'fields when re-edited', (tester) async { final ref = await _pump(tester); From 2b1c355574e241395c75646bba68cb32538565f5 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 25 Sep 2026 16:15:21 -0400 Subject: [PATCH 09/10] fix(blender): show a gas fill's pressure to the blender's tenth of a bar The line form's "Fill pressure" text and its generated description rounded the added pressure to whole bar, so a 50.25 to 200.75 bar fill read as 151 bar while the bill charged for 150.5. Both now use pressureDecimalsFor, the precision the invoice card, the billing card and the procedure already print, so the form, the label and the bill agree. Refs #2302 --- .../blender/blender_line_edit_sheet.dart | 14 ++++- .../gas_calculators/blender_invoice_test.dart | 57 +++++++++++++++---- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart index 08d8a792f2..84e58f7a3e 100644 --- a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart @@ -203,7 +203,12 @@ class _BlenderLineEditSheetState extends ConsumerState { ) => [ gasName, if (cylinderLiters != null) units.formatTankVolume(cylinderLiters, null), - units.formatPressure(addedBar), + // The blender's own pressure precision, so a label reads the fill that + // was billed rather than a rounded one. + units.formatPressure( + addedBar, + decimals: pressureDecimalsFor(units.settings.pressureUnit), + ), ].join(' · '); /// The name the chosen gas is stored and printed under, the same one a @@ -513,7 +518,12 @@ class _BlenderLineEditSheetState extends ConsumerState { const SizedBox(height: 12), Text( context.l10n.gasCalculators_blender_lineFillPressure( - addedBar == null ? '--' : units.formatPressure(addedBar), + addedBar == null + ? '--' + : units.formatPressure( + addedBar, + decimals: pressureDecimalsFor(settings.pressureUnit), + ), ), key: const Key('blender-line-fill-pressure'), style: resultStyle, diff --git a/test/features/gas_calculators/blender_invoice_test.dart b/test/features/gas_calculators/blender_invoice_test.dart index b6df3f58ca..c478adb8c0 100644 --- a/test/features/gas_calculators/blender_invoice_test.dart +++ b/test/features/gas_calculators/blender_invoice_test.dart @@ -578,7 +578,7 @@ void main() { expect(line.cylinderLiters, 12); // No description typed: the label is generated from the fill. expect(fills.single.label, startsWith('Helium · 12')); - expect(fills.single.label, endsWith('150 bar')); + expect(fills.single.label, endsWith('150.0 bar')); }); testWidgets('a gas without a price is charged at 0, and the form says ' @@ -924,7 +924,7 @@ void main() { ref.read(blenderBilledFillsProvider.notifier).state = const [ BilledFill( id: 'a', - label: 'Helium · 12 L · 150 bar', + label: 'Helium · 12 L · 150.0 bar', lines: [ BilledGasLine( gas: 'Helium', @@ -940,9 +940,9 @@ void main() { ]; await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150 bar')); + await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150.0 bar')); await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Helium · 12 L · 150 bar')); + await tester.tap(find.text('Edit Helium · 12 L · 150.0 bar')); await tester.pumpAndSettle(); expect( @@ -974,7 +974,7 @@ void main() { ref.read(blenderBilledFillsProvider.notifier).state = const [ BilledFill( id: 'a', - label: 'Helium · 12 L · 150 bar', + label: 'Helium · 12 L · 150.0 bar', lines: [ BilledGasLine( gas: 'Helium', @@ -990,9 +990,9 @@ void main() { ]; await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150 bar')); + await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150.0 bar')); await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Helium · 12 L · 150 bar')); + await tester.tap(find.text('Edit Helium · 12 L · 150.0 bar')); await tester.pumpAndSettle(); await tester.tap(find.text('Free amount')); await tester.pumpAndSettle(); @@ -1000,7 +1000,7 @@ void main() { await tester.pumpAndSettle(); final fill = ref.read(blenderBilledFillsProvider).single; - expect(fill.label, 'Helium · 12 L · 150 bar'); + expect(fill.label, 'Helium · 12 L · 150.0 bar'); expect(fill.isManual, isTrue); expect(fill.total, 27); }); @@ -1063,7 +1063,7 @@ void main() { ref.read(blenderBilledFillsProvider.notifier).state = const [ BilledFill( id: 'a', - label: 'Helium · 12 L · 150 bar', + label: 'Helium · 12 L · 150.0 bar', lines: [ BilledGasLine( gas: 'Helium', @@ -1079,15 +1079,15 @@ void main() { ]; await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150 bar')); + await tester.tap(find.byTooltip('Actions for Helium · 12 L · 150.0 bar')); await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Helium · 12 L · 150 bar')); + await tester.tap(find.text('Edit Helium · 12 L · 150.0 bar')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Save')); await tester.pumpAndSettle(); final fill = ref.read(blenderBilledFillsProvider).single; - expect(fill.label, 'Helium · 12 L · 150 bar'); + expect(fill.label, 'Helium · 12 L · 150.0 bar'); expect(fill.total, 27); }); @@ -1142,6 +1142,39 @@ void main() { expect(ref.read(blenderBilledFillsProvider).last.total, 12.5); }); + testWidgets('a fractional fill pressure is shown and labelled to the ' + 'blender\'s tenth of a bar (#2302 review)', (tester) async { + final ref = await _pump(tester); + await _openAddLine(tester); + await tester.enterText( + find.byKey(const Key('blender-line-cylinder')), + '12', + ); + await tester.enterText( + find.byKey(const Key('blender-line-start-pressure')), + '50.25', + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '200.75', + ); + await tester.pumpAndSettle(); + + // Rounded to whole bar this would read 151, a fill that was not made. + expect( + tester + .widget(find.byKey(const Key('blender-line-fill-pressure'))) + .data, + contains('150.5 bar'), + ); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final fill = ref.read(blenderBilledFillsProvider).single; + expect(fill.manualGasLine!.addedBar, closeTo(150.5, 1e-9)); + expect(fill.label, endsWith('150.5 bar')); + }); + testWidgets('a computed fill offers neither the kind switch nor the gas ' 'fields when re-edited', (tester) async { final ref = await _pump(tester); From 0816da734cdd7c26f7c56852a54c9b12d0b6c84b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 25 Sep 2026 16:15:21 -0400 Subject: [PATCH 10/10] fix(blender): recognise a generated gas fill label after a language change The check that tells a generated description from a typed one rebuilt its candidates under the current locale only. A label written under a comma-decimal language ("12,5 L") never matched once the app was switched to English ("12.5 L"), so it was kept as typed text and went stale when the fill changed. The check now also tries every supported locale, the same way it already tries every unit. Refs #2302 --- .../blender/blender_line_edit_sheet.dart | 32 ++++++---- .../gas_calculators/blender_invoice_test.dart | 59 +++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart index 84e58f7a3e..252346403a 100644 --- a/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/currency.dart'; @@ -14,6 +15,7 @@ import 'package:submersion/features/gas_calculators/presentation/widgets/blender import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:submersion/l10n/l10n_extension.dart'; /// The two kinds of line "Add a line" can put on the bill (issue #2302). @@ -113,7 +115,8 @@ class _BlenderLineEditSheetState extends ConsumerState { // A label this sheet generated is left blank rather than kept as typed // text, so changing the pressure or the gas regenerates it. Checked in - // every unit combination: the diver may have switched units since. + // every unit combination and app language: the diver may have switched + // either since. final generated = gasLine != null && _isGeneratedLabel(fill!.label, gasLine, settings); _label = TextEditingController( @@ -170,25 +173,32 @@ class _BlenderLineEditSheetState extends ConsumerState { } /// Whether [label] is what [_generatedLabel] made for [line], in any - /// pressure and volume unit. + /// pressure and volume unit and under any of the app's languages. + /// + /// The language matters because the label's numbers carry that locale's + /// decimal separator: "12,5 L" written under de is not "12.5 L" read back + /// under en. bool _isGeneratedLabel( String label, BilledGasLine line, AppSettings settings, ) { - for (final pressure in PressureUnit.values) { - for (final volume in VolumeUnit.values) { - final units = UnitFormatter( - settings.copyWith(pressureUnit: pressure, volumeUnit: volume), - ); - if (label == - _generatedLabel( + for (final locale in AppLocalizations.supportedLocales) { + for (final pressure in PressureUnit.values) { + for (final volume in VolumeUnit.values) { + final units = UnitFormatter( + settings.copyWith(pressureUnit: pressure, volumeUnit: volume), + ); + final candidate = Intl.withLocale( + locale.toLanguageTag(), + () => _generatedLabel( line.gas, line.cylinderLiters, line.addedBar, units, - )) { - return true; + ), + ); + if (label == candidate) return true; } } } diff --git a/test/features/gas_calculators/blender_invoice_test.dart b/test/features/gas_calculators/blender_invoice_test.dart index c478adb8c0..bce80fb92f 100644 --- a/test/features/gas_calculators/blender_invoice_test.dart +++ b/test/features/gas_calculators/blender_invoice_test.dart @@ -1175,6 +1175,65 @@ void main() { expect(fill.label, endsWith('150.5 bar')); }); + testWidgets('a label generated under a comma-decimal locale still ' + 'regenerates after the app language changes (#2302 review)', ( + tester, + ) async { + final previousLocale = Intl.defaultLocale; + addTearDown(() => Intl.defaultLocale = previousLocale); + Intl.defaultLocale = 'de'; + final ref = await _pump(tester); + + await _openAddLine(tester); + await _pickGas(tester, 'Helium'); + await tester.enterText( + find.byKey(const Key('blender-line-cylinder')), + '12,5', + ); + await tester.enterText( + find.byKey(const Key('blender-line-start-pressure')), + '0', + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '150', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + final saved = ref.read(blenderBilledFillsProvider).single.label; + expect(saved, 'Helium · 12,5 L · 150,0 bar'); + + Intl.defaultLocale = 'en'; + await tester.tap(find.byTooltip('Actions for $saved')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit $saved')); + await tester.pumpAndSettle(); + + // Recognised as generated, so it is left blank to be regenerated. + expect( + tester + .widget( + find.byKey(const Key('blender-line-description')), + ) + .controller! + .text, + isEmpty, + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '200', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect( + ref.read(blenderBilledFillsProvider).single.label, + 'Helium · 12.5 L · 200.0 bar', + ); + }); + testWidgets('a computed fill offers neither the kind switch nor the gas ' 'fields when re-edited', (tester) async { final ref = await _pump(tester);