diff --git a/lib/features/gas_calculators/domain/blending/billed_fill.dart b/lib/features/gas_calculators/domain/blending/billed_fill.dart index 3600869958..c076ace2e3 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,30 @@ 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. + /// + /// [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 + /// 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 +72,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 +99,6 @@ class BilledFill { required this.label, required this.lines, required this.total, - this.customMix, }); final String id; @@ -119,19 +107,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 +130,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 +150,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 +169,6 @@ class BilledFill { .toList() : const [], total: total is num ? total.toDouble() : null, - customMix: BilledCustomMix.fromJson(json['customMix']), ); } } diff --git a/lib/features/gas_calculators/domain/blending/blend_billing.dart b/lib/features/gas_calculators/domain/blending/blend_billing.dart index d5f4c2a887..51bdb38efd 100644 --- a/lib/features/gas_calculators/domain/blending/blend_billing.dart +++ b/lib/features/gas_calculators/domain/blending/blend_billing.dart @@ -110,3 +110,59 @@ 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; + } + // 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 ?? 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: cost, + ); +} 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..252346403a --- /dev/null +++ b/lib/features/gas_calculators/presentation/widgets/blender/blender_line_edit_sheet.dart @@ -0,0 +1,667 @@ +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'; +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/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). +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; + + /// 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 + /// 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. Checked in + // every unit combination and app language: the diver may have switched + // either since. + final generated = + gasLine != null && _isGeneratedLabel(fill!.label, gasLine, settings); + _label = TextEditingController( + 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. + _amount = TextEditingController( + text: fill?.total == null ? '' : formatRoundedForInput(fill!.total!, 2), + ); + final double cylinderLiters = + gasLine?.cylinderLiters ?? ref.read(blenderCylinderLitersProvider); + _seedCylinder = formatRoundedForInput( + litersToDisplayVolume(cylinderLiters, settings), + 2, + ); + _cylinder = TextEditingController(text: _seedCylinder); + final double startBar = gasLine?.startBar ?? 0; + final double endBar = + gasLine?.endBar ?? ref.read(blenderTargetPressureProvider); + // 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 + void dispose() { + _label.dispose(); + _amount.dispose(); + _cylinder.dispose(); + _startPressure.dispose(); + _endPressure.dispose(); + super.dispose(); + } + + /// Whether [label] is what [_generatedLabel] made for [line], in any + /// 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 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, + ), + ); + if (label == candidate) return true; + } + } + } + return false; + } + + String _generatedLabel( + String gasName, + double? cylinderLiters, + double addedBar, + UnitFormatter units, + ) => [ + gasName, + if (cylinderLiters != null) units.formatTankVolume(cylinderLiters, null), + // 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 + /// 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) { + 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 = smartParseUserDecimal(_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 = smartParseUserDecimal(_startPressure.text); + final end = smartParseUserDecimal(_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: smartParseUserDecimal(_amount.text), + lines: _kindEditable ? const [] : null, + ), + ); + return; + } + + final settings = ref.read(settingsProvider); + final units = UnitFormatter(settings); + final unchanged = _unchangedGasLine; + if (unchanged != null) { + final fill = widget.fill!; + Navigator.of(context).pop( + BlenderLineEdit( + // 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], + ), + ); + return; + } + final liters = _cylinderLiters(settings); + if (liters == null) { + setState( + () => _error = context.l10n.gasCalculators_blender_lineNeedsCylinder, + ); + return; + } + if (smartParseUserDecimal(_startPressure.text) == null || + smartParseUserDecimal(_endPressure.text) == null) { + setState( + () => _error = context.l10n.gasCalculators_blender_lineNeedsPressure, + ); + 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( + smartParseUserDecimal(_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; + // 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), + ], + 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); + // 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( + 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( + addedBar == null + ? '--' + : units.formatPressure( + addedBar, + decimals: pressureDecimalsFor(settings.pressureUnit), + ), + ), + key: const Key('blender-line-fill-pressure'), + style: resultStyle, + ), + const SizedBox(height: 4), + Text( + context.l10n.gasCalculators_blender_lineComputedAmount( + amount == null ? '--' : formatMoney(amount, 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})', + // Typing a size replaces the preset's exact one. + onChanged: () => _presetLiters = null, + ), + ), + 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(() { + _presetLiters = preset.volumeLiters; + _cylinder.text = formatRoundedForInput( + litersToDisplayVolume(preset.volumeLiters, settings), + 2, + ); + _endPressure.text = formatRoundedForInput( + units.convertPressure(preset.workingPressureBar), + 2, + ); + _error = null; + }), + 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, { + VoidCallback? onChanged, + }) { + return TextField( + key: key, + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + onChanged: (_) => setState(() { + onChanged?.call(); + _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..95ccae8d45 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -142,7 +142,19 @@ "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_lineNeedsPressure": "أدخل ضغطًا ابتدائيًا وضغطًا نهائيًا.", + "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..f668395938 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -150,7 +150,19 @@ "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_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", "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..cf96e29094 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -8310,7 +8310,33 @@ "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_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": { + "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..2953f5baf4 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -142,7 +142,19 @@ "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_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", "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..0bc9660c81 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -142,7 +142,19 @@ "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_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", "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..688204c8f6 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -142,7 +142,19 @@ "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_lineNeedsPressure": "יש להזין לחץ התחלתי ולחץ סופי.", + "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..2b24aa0aed 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -142,7 +142,19 @@ "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_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", "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..7a9b6da2c8 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -142,7 +142,19 @@ "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_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", "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..bde573063e 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -23485,9 +23485,81 @@ 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_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: + /// **'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..235df60583 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -14008,8 +14008,51 @@ 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_lineNeedsPressure => + 'أدخل ضغطًا ابتدائيًا وضغطًا نهائيًا.'; + + @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..204c7bef55 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -14227,7 +14227,52 @@ 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_lineNeedsPressure => + 'Gib einen Anfangs- und einen Enddruck ein.'; + + @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..bdc7cf46c2 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -14019,7 +14019,52 @@ 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_lineNeedsPressure => + 'Enter a start and an end 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..ab19c9e633 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -14233,7 +14233,52 @@ 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_lineNeedsPressure => + 'Introduce una presión inicial y una final.'; + + @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..a534301796 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -14293,7 +14293,52 @@ 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_lineNeedsPressure => + 'Saisissez une pression initiale et une pression finale.'; + + @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..f4e0c06340 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -13909,8 +13909,51 @@ 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_lineNeedsPressure => + 'יש להזין לחץ התחלתי ולחץ סופי.'; + + @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..fd95e9550d 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -14201,7 +14201,52 @@ 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_lineNeedsPressure => + 'Adjon meg egy kezdő- és egy végnyomást.'; + + @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..eaf1b5dcf2 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -14248,7 +14248,52 @@ 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_lineNeedsPressure => + 'Inserisci una pressione iniziale e una finale.'; + + @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..05617cb8d6 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -14138,7 +14138,52 @@ 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_lineNeedsPressure => + 'Voer een begin- en een einddruk in.'; + + @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..51f5f78114 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -14245,7 +14245,52 @@ 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_lineNeedsPressure => + 'Introduza uma pressão inicial e uma final.'; + + @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..df713bf06a 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -13567,7 +13567,48 @@ 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_lineNeedsPressure => '请输入初始压力和最终压力。'; + + @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..cc13d46e84 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -142,7 +142,19 @@ "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_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", "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..5b38c47aeb 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -142,7 +142,19 @@ "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_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", "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..81f6e73a69 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -142,7 +142,19 @@ "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_lineNeedsPressure": "请输入初始压力和最终压力。", + "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..bce80fb92f 100644 --- a/test/features/gas_calculators/blender_invoice_test.dart +++ b/test/features/gas_calculators/blender_invoice_test.dart @@ -4,13 +4,17 @@ 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'; +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 +22,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 +55,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 +176,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('copyWith can clear a custom mix', () { + 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 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 +448,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 +470,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 +489,753 @@ 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')), + find.byKey(const Key('blender-line-start-pressure')), + '50', + ); + await tester.enterText( + find.byKey(const Key('blender-line-end-pressure')), + '200', + ); + 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.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.0 bar')); + }); + + 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', ); - // 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(); + 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: '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 Twinset')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Twinset')); + await tester.pumpAndSettle(); + + 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'); + + 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.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.label, 'Twinset'); + expect(fills.single.manualGasLine!.addedBar, 100); + expect(fills.single.total, closeTo(18, 1e-9)); }); - testWidgets('re-editing a manual line pre-fills its saved mix', ( - 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('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: 'Tx 21/35', - lines: [], - total: 30, - customMix: BilledCustomMix(cylinderLiters: 11.1, o2: 21, he: 35), + 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 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₂ (%)'), + // 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(o2Field.controller!.text, '21'); - final heField = tester.widget( - find.widgetWithText(TextField, 'He (%)'), + expect( + tester + .widget(find.byKey(const Key('blender-line-computed-amount'))) + .data, + contains('27.00'), ); - expect(heField.controller!.text, '35'); - // 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-description')), + 'Doubles', + ); 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); + 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('a computed fill offers no mix fields when re-edited', ( + 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 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.0 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.0 bar')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Helium · 12 L · 150.0 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.0 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.0 bar')); + await tester.pumpAndSettle(); + 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(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + final fill = ref.read(blenderBilledFillsProvider).single; + expect(fill.label, 'Helium · 12 L · 150.0 bar'); + expect(fill.isManual, isTrue); + 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.0 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.0 bar')); + await tester.pumpAndSettle(); + 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.0 bar'); + 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 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 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); ref.read(blenderBilledFillsProvider.notifier).state = const [ BilledFill( @@ -513,8 +1252,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 +1514,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', diff --git a/test/features/gas_calculators/domain/blend_billing_test.dart b/test/features/gas_calculators/domain/blend_billing_test.dart index 717622b9ea..42acd48f79 100644 --- a/test/features/gas_calculators/domain/blend_billing_test.dart +++ b/test/features/gas_calculators/domain/blend_billing_test.dart @@ -178,4 +178,147 @@ 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('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('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( + waterLiters: double.nan, + startBar: 0, + endBar: 100, + pricePer100: 1, + ), + isNull, + ); + expect( + manualGasFillCost( + waterLiters: 12, + startBar: 0, + endBar: double.infinity, + pricePer100: 1, + ), + isNull, + ); + }); + }); }