Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 38 additions & 53 deletions lib/features/gas_calculators/domain/blending/billed_fill.dart
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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
Expand All @@ -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<String, dynamic> 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) {
Expand All @@ -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<String, dynamic> 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,
);
}
}
Expand All @@ -110,7 +99,6 @@ class BilledFill {
required this.label,
required this.lines,
required this.total,
this.customMix,
});

final String id;
Expand All @@ -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<BilledGasLine> 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,
Expand All @@ -141,29 +130,26 @@ 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<BilledGasLine>? 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<String, dynamic> toJson() => {
'id': id,
'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) {
Expand All @@ -183,7 +169,6 @@ class BilledFill {
.toList()
: const [],
total: total is num ? total.toDouble() : null,
customMix: BilledCustomMix.fromJson(json['customMix']),
);
}
}
Expand Down
56 changes: 56 additions & 0 deletions lib/features/gas_calculators/domain/blending/blend_billing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
),
),
),
],
),
);
Expand Down
Loading
Loading