Skip to content

Commit dc29e37

Browse files
authored
Merge pull request #275 from readme42/gas-bar
feat(dive_profile_chart): add used gas
2 parents 7513048 + d703012 commit dc29e37

43 files changed

Lines changed: 5214 additions & 3581 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import 'package:submersion/features/dive_log/domain/entities/dive.dart';
2+
import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart';
3+
4+
/// One contiguous period during a dive when a single gas mix was breathed.
5+
///
6+
/// Drives the gas-usage timeline strip rendered below the dive profile.
7+
/// Times are in seconds from dive start; [endSeconds] is exclusive.
8+
class GasUsageSegment {
9+
final int startSeconds;
10+
final int endSeconds;
11+
final GasMix gasMix;
12+
final String label;
13+
final String? tankName;
14+
15+
const GasUsageSegment({
16+
required this.startSeconds,
17+
required this.endSeconds,
18+
required this.gasMix,
19+
required this.label,
20+
this.tankName,
21+
});
22+
23+
int get durationSeconds => endSeconds - startSeconds;
24+
}
25+
26+
/// Builds gas-usage segments for the dive profile gas-timeline strip.
27+
///
28+
/// Algorithm:
29+
/// 1. Pick the starting tank (lowest [DiveTank.order], or the first tank).
30+
/// 2. Sort gas switches by timestamp and clamp to dive bounds.
31+
/// 3. The initial segment runs from t=0 to the first switch's timestamp
32+
/// (skipped if the first switch is exactly at t=0).
33+
/// 4. Each subsequent segment runs from one switch to the next, ending at
34+
/// [diveDurationSeconds] for the final switch.
35+
/// 5. Adjacent segments with identical gas mixes are merged so a switch back
36+
/// to the same gas does not produce a visible seam.
37+
///
38+
/// Returns an empty list when there are no tanks or the dive has no
39+
/// duration — the caller should hide the strip in that case.
40+
List<GasUsageSegment> buildGasUsageSegments({
41+
required List<DiveTank> tanks,
42+
required List<GasSwitchWithTank> gasSwitches,
43+
required int diveDurationSeconds,
44+
}) {
45+
if (tanks.isEmpty || diveDurationSeconds <= 0) {
46+
return const <GasUsageSegment>[];
47+
}
48+
49+
final tankById = {for (final t in tanks) t.id: t};
50+
final startingTank = ([
51+
...tanks,
52+
]..sort((a, b) => a.order.compareTo(b.order))).first;
53+
54+
final inBoundsSwitches =
55+
([...gasSwitches]..sort((a, b) => a.timestamp.compareTo(b.timestamp)))
56+
.where((s) => s.timestamp >= 0 && s.timestamp <= diveDurationSeconds)
57+
.toList(growable: false);
58+
59+
if (inBoundsSwitches.isEmpty) {
60+
return [
61+
GasUsageSegment(
62+
startSeconds: 0,
63+
endSeconds: diveDurationSeconds,
64+
gasMix: startingTank.gasMix,
65+
label: startingTank.gasMix.name,
66+
tankName: startingTank.name,
67+
),
68+
];
69+
}
70+
71+
final segments = <GasUsageSegment>[];
72+
73+
final firstSwitch = inBoundsSwitches.first;
74+
if (firstSwitch.timestamp > 0) {
75+
segments.add(
76+
GasUsageSegment(
77+
startSeconds: 0,
78+
endSeconds: firstSwitch.timestamp,
79+
gasMix: startingTank.gasMix,
80+
label: startingTank.gasMix.name,
81+
tankName: startingTank.name,
82+
),
83+
);
84+
}
85+
86+
for (var i = 0; i < inBoundsSwitches.length; i++) {
87+
final cur = inBoundsSwitches[i];
88+
final endSec = i + 1 < inBoundsSwitches.length
89+
? inBoundsSwitches[i + 1].timestamp
90+
: diveDurationSeconds;
91+
if (cur.timestamp >= endSec) continue;
92+
final tank = tankById[cur.tankId];
93+
final gasMix = GasMix(o2: cur.o2Fraction * 100, he: cur.heFraction * 100);
94+
segments.add(
95+
GasUsageSegment(
96+
startSeconds: cur.timestamp,
97+
endSeconds: endSec,
98+
gasMix: gasMix,
99+
label: gasMix.name,
100+
tankName: tank?.name,
101+
),
102+
);
103+
}
104+
105+
return _mergeAdjacentSameGas(segments);
106+
}
107+
108+
List<GasUsageSegment> _mergeAdjacentSameGas(List<GasUsageSegment> segments) {
109+
if (segments.length < 2) return segments;
110+
final merged = <GasUsageSegment>[segments.first];
111+
for (var i = 1; i < segments.length; i++) {
112+
final last = merged.last;
113+
final cur = segments[i];
114+
final sameGas =
115+
last.gasMix.o2 == cur.gasMix.o2 && last.gasMix.he == cur.gasMix.he;
116+
if (sameGas && last.endSeconds == cur.startSeconds) {
117+
merged[merged.length - 1] = GasUsageSegment(
118+
startSeconds: last.startSeconds,
119+
endSeconds: cur.endSeconds,
120+
gasMix: last.gasMix,
121+
label: last.label,
122+
tankName: last.tankName,
123+
);
124+
} else {
125+
merged.add(cur);
126+
}
127+
}
128+
return merged;
129+
}

lib/features/dive_log/domain/entities/dive.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,10 +918,12 @@ class GasMix extends Equatable {
918918
bool get isAir => o2 >= 20 && o2 <= 22 && he == 0;
919919
bool get isNitrox => o2 > 22 && he == 0;
920920
bool get isTrimix => he > 0;
921+
bool get isOxygen => o2 >= 99 && he == 0;
921922

922923
String get name {
923924
if (isAir) return 'Air';
924925
if (isTrimix) return 'Tx $roundedO2/$roundedHe';
926+
if (isOxygen) return 'O2';
925927
if (isNitrox) return 'EAN$roundedO2';
926928
return '$roundedO2% O2';
927929
}

lib/features/dive_log/domain/entities/gas_switch.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ class GasSwitchWithTank extends Equatable {
108108
/// Whether this is air
109109
bool get isAir => o2Fraction >= 0.20 && o2Fraction <= 0.22 && heFraction == 0;
110110

111+
/// Whether this is pure oxygen (used as deco gas)
112+
bool get isOxygen => o2Fraction >= 0.99 && heFraction == 0;
113+
111114
/// ppO2 at switch depth
112115
double get ppO2AtDepth {
113116
if (gasSwitch.depth == null) return o2Fraction;

lib/features/dive_log/presentation/pages/dive_detail_page.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import 'package:submersion/features/marine_life/domain/entities/species.dart';
2828
import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart';
2929
import 'package:submersion/features/settings/presentation/providers/export_providers.dart';
3030
import 'package:submersion/features/settings/presentation/providers/settings_providers.dart';
31+
import 'package:submersion/features/dive_log/data/services/gas_usage_segments_service.dart';
3132
import 'package:submersion/features/dive_log/data/services/profile_analysis_service.dart';
3233
import 'package:submersion/features/dive_log/data/services/profile_markers_service.dart';
3334
import 'package:submersion/features/dive_log/domain/entities/cylinder_sac.dart';
@@ -1289,6 +1290,19 @@ class _DiveDetailPageState extends ConsumerState<DiveDetailPage> {
12891290
tanks: dive.tanks,
12901291
tankPressures: tankPressures,
12911292
gasSwitches: gasSwitchesAsync.valueOrNull,
1293+
gasSegments:
1294+
(dive.tanks.isEmpty || dive.profile.isEmpty)
1295+
? null
1296+
: buildGasUsageSegments(
1297+
tanks: dive.tanks,
1298+
gasSwitches:
1299+
gasSwitchesAsync.valueOrNull ?? const [],
1300+
diveDurationSeconds:
1301+
dive.profile.last.timestamp,
1302+
),
1303+
diveDurationSeconds: dive.profile.isEmpty
1304+
? null
1305+
: dive.profile.last.timestamp,
12921306
computerProfiles: multiComputerProfiles,
12931307
visibleComputers: effectiveVisible,
12941308
computerLineColors: computerLineColors,
@@ -4943,6 +4957,21 @@ class _FullscreenProfilePageState
49434957
tanks: dive.tanks,
49444958
tankPressures: widget.tankPressures,
49454959
gasSwitches: widget.gasSwitches,
4960+
gasSegments: (dive.tanks.isEmpty || dive.profile.isEmpty)
4961+
? null
4962+
: buildGasUsageSegments(
4963+
tanks: dive.tanks,
4964+
gasSwitches: widget.gasSwitches ?? const [],
4965+
diveDurationSeconds: dive.profile.last.timestamp,
4966+
),
4967+
diveDurationSeconds: dive.profile.isEmpty
4968+
? null
4969+
: dive.profile.last.timestamp,
4970+
highlightedTimestamp:
4971+
_selectedPointIndex != null &&
4972+
_selectedPointIndex! < dive.profile.length
4973+
? dive.profile[_selectedPointIndex!].timestamp
4974+
: null,
49464975
onPointSelected: (index) {
49474976
setState(() {
49484977
_selectedPoint = index != null

lib/features/dive_log/presentation/providers/gas_switch_providers.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ final gasSwitchesProvider =
1212
});
1313

1414
/// Gas type classification for coloring
15-
enum GasType { air, nitrox, trimix }
15+
enum GasType { air, nitrox, oxygen, trimix }
1616

1717
/// Extension to determine gas type from GasSwitchWithTank
1818
extension GasSwitchWithTankGasType on GasSwitchWithTank {
1919
GasType get gasType {
2020
if (isTrimix) return GasType.trimix;
21+
if (isOxygen) return GasType.oxygen;
2122
if (isNitrox) return GasType.nitrox;
2223
return GasType.air;
2324
}
@@ -27,6 +28,7 @@ extension GasSwitchWithTankGasType on GasSwitchWithTank {
2728
extension GasTypeFromFractions on ({double o2, double he}) {
2829
GasType get gasType {
2930
if (he > 0) return GasType.trimix;
31+
if (o2 >= 0.99) return GasType.oxygen;
3032
if (o2 > 0.22) return GasType.nitrox;
3133
return GasType.air;
3234
}

lib/features/dive_log/presentation/providers/profile_legend_provider.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ class ProfileLegendState {
5555
// Per-tank pressure visibility (keyed by tank ID)
5656
final Map<String, bool> showTankPressure;
5757

58+
// Gas timeline strip visibility
59+
final bool showGas;
60+
5861
// Collapsible section expanded/collapsed state (session-only)
5962
final Map<String, bool> sectionExpanded;
6063

@@ -88,6 +91,7 @@ class ProfileLegendState {
8891
this.ttsSource = MetricDataSource.calculated,
8992
this.cnsSource = MetricDataSource.calculated,
9093
this.showTankPressure = const {},
94+
this.showGas = true,
9195
this.sectionExpanded = const {
9296
'overlays': true,
9397
'decompression': true,
@@ -159,6 +163,7 @@ class ProfileLegendState {
159163
MetricDataSource? ttsSource,
160164
MetricDataSource? cnsSource,
161165
Map<String, bool>? showTankPressure,
166+
bool? showGas,
162167
Map<String, bool>? sectionExpanded,
163168
}) {
164169
return ProfileLegendState(
@@ -193,6 +198,7 @@ class ProfileLegendState {
193198
ttsSource: ttsSource ?? this.ttsSource,
194199
cnsSource: cnsSource ?? this.cnsSource,
195200
showTankPressure: showTankPressure ?? this.showTankPressure,
201+
showGas: showGas ?? this.showGas,
196202
sectionExpanded: sectionExpanded ?? this.sectionExpanded,
197203
);
198204
}
@@ -231,6 +237,7 @@ class ProfileLegendState {
231237
ttsSource == other.ttsSource &&
232238
cnsSource == other.cnsSource &&
233239
mapEquals(showTankPressure, other.showTankPressure) &&
240+
showGas == other.showGas &&
234241
mapEquals(sectionExpanded, other.sectionExpanded);
235242

236243
@override
@@ -264,6 +271,7 @@ class ProfileLegendState {
264271
ttsSource,
265272
cnsSource,
266273
...showTankPressure.entries,
274+
showGas,
267275
...sectionExpanded.entries,
268276
]);
269277
}
@@ -458,6 +466,11 @@ class ProfileLegend extends _$ProfileLegend {
458466
);
459467
}
460468

469+
/// Toggle visibility of the gas-usage timeline strip below the chart.
470+
void toggleGas() {
471+
state = state.copyWith(showGas: !state.showGas);
472+
}
473+
461474
/// Toggle visibility for a specific tank's pressure line
462475
void toggleTankPressure(String tankId) {
463476
final current = state.showTankPressure[tankId] ?? true;

0 commit comments

Comments
 (0)