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
129 changes: 129 additions & 0 deletions lib/features/dive_log/data/services/gas_usage_segments_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import 'package:submersion/features/dive_log/domain/entities/dive.dart';
import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart';

/// One contiguous period during a dive when a single gas mix was breathed.
///
/// Drives the gas-usage timeline strip rendered below the dive profile.
/// Times are in seconds from dive start; [endSeconds] is exclusive.
class GasUsageSegment {
final int startSeconds;
final int endSeconds;
final GasMix gasMix;
final String label;
final String? tankName;

const GasUsageSegment({
required this.startSeconds,
required this.endSeconds,
required this.gasMix,
required this.label,
this.tankName,
});

int get durationSeconds => endSeconds - startSeconds;
}

/// Builds gas-usage segments for the dive profile gas-timeline strip.
///
/// Algorithm:
/// 1. Pick the starting tank (lowest [DiveTank.order], or the first tank).
/// 2. Sort gas switches by timestamp and clamp to dive bounds.
/// 3. The initial segment runs from t=0 to the first switch's timestamp
/// (skipped if the first switch is exactly at t=0).
/// 4. Each subsequent segment runs from one switch to the next, ending at
/// [diveDurationSeconds] for the final switch.
/// 5. Adjacent segments with identical gas mixes are merged so a switch back
/// to the same gas does not produce a visible seam.
///
/// Returns an empty list when there are no tanks or the dive has no
/// duration — the caller should hide the strip in that case.
List<GasUsageSegment> buildGasUsageSegments({
required List<DiveTank> tanks,
required List<GasSwitchWithTank> gasSwitches,
required int diveDurationSeconds,
}) {
if (tanks.isEmpty || diveDurationSeconds <= 0) {
return const <GasUsageSegment>[];
}

final tankById = {for (final t in tanks) t.id: t};
final startingTank = ([
...tanks,
]..sort((a, b) => a.order.compareTo(b.order))).first;

final inBoundsSwitches =
([...gasSwitches]..sort((a, b) => a.timestamp.compareTo(b.timestamp)))
.where((s) => s.timestamp >= 0 && s.timestamp <= diveDurationSeconds)
.toList(growable: false);

if (inBoundsSwitches.isEmpty) {
return [
GasUsageSegment(
startSeconds: 0,
endSeconds: diveDurationSeconds,
gasMix: startingTank.gasMix,
label: startingTank.gasMix.name,
tankName: startingTank.name,
),
];
}

final segments = <GasUsageSegment>[];

final firstSwitch = inBoundsSwitches.first;
if (firstSwitch.timestamp > 0) {
segments.add(
GasUsageSegment(
startSeconds: 0,
endSeconds: firstSwitch.timestamp,
gasMix: startingTank.gasMix,
label: startingTank.gasMix.name,
tankName: startingTank.name,
),
);
}

for (var i = 0; i < inBoundsSwitches.length; i++) {
final cur = inBoundsSwitches[i];
final endSec = i + 1 < inBoundsSwitches.length
? inBoundsSwitches[i + 1].timestamp
: diveDurationSeconds;
if (cur.timestamp >= endSec) continue;
final tank = tankById[cur.tankId];
final gasMix = GasMix(o2: cur.o2Fraction * 100, he: cur.heFraction * 100);
segments.add(
GasUsageSegment(
startSeconds: cur.timestamp,
endSeconds: endSec,
gasMix: gasMix,
label: gasMix.name,
tankName: tank?.name,
),
);
}

return _mergeAdjacentSameGas(segments);
}

List<GasUsageSegment> _mergeAdjacentSameGas(List<GasUsageSegment> segments) {
if (segments.length < 2) return segments;
final merged = <GasUsageSegment>[segments.first];
for (var i = 1; i < segments.length; i++) {
final last = merged.last;
final cur = segments[i];
final sameGas =
last.gasMix.o2 == cur.gasMix.o2 && last.gasMix.he == cur.gasMix.he;
if (sameGas && last.endSeconds == cur.startSeconds) {
merged[merged.length - 1] = GasUsageSegment(
startSeconds: last.startSeconds,
endSeconds: cur.endSeconds,
gasMix: last.gasMix,
label: last.label,
tankName: last.tankName,
);
} else {
merged.add(cur);
}
}
return merged;
}
2 changes: 2 additions & 0 deletions lib/features/dive_log/domain/entities/dive.dart
Original file line number Diff line number Diff line change
Expand Up @@ -918,10 +918,12 @@ class GasMix extends Equatable {
bool get isAir => o2 >= 20 && o2 <= 22 && he == 0;
bool get isNitrox => o2 > 22 && he == 0;
bool get isTrimix => he > 0;
bool get isOxygen => o2 >= 99 && he == 0;

String get name {
if (isAir) return 'Air';
if (isTrimix) return 'Tx $roundedO2/$roundedHe';
if (isOxygen) return 'O2';
if (isNitrox) return 'EAN$roundedO2';
return '$roundedO2% O2';
}
Expand Down
3 changes: 3 additions & 0 deletions lib/features/dive_log/domain/entities/gas_switch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ class GasSwitchWithTank extends Equatable {
/// Whether this is air
bool get isAir => o2Fraction >= 0.20 && o2Fraction <= 0.22 && heFraction == 0;

/// Whether this is pure oxygen (used as deco gas)
bool get isOxygen => o2Fraction >= 0.99 && heFraction == 0;

/// ppO2 at switch depth
double get ppO2AtDepth {
if (gasSwitch.depth == null) return o2Fraction;
Expand Down
29 changes: 29 additions & 0 deletions lib/features/dive_log/presentation/pages/dive_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import 'package:submersion/features/marine_life/domain/entities/species.dart';
import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart';
import 'package:submersion/features/settings/presentation/providers/export_providers.dart';
import 'package:submersion/features/settings/presentation/providers/settings_providers.dart';
import 'package:submersion/features/dive_log/data/services/gas_usage_segments_service.dart';
import 'package:submersion/features/dive_log/data/services/profile_analysis_service.dart';
import 'package:submersion/features/dive_log/data/services/profile_markers_service.dart';
import 'package:submersion/features/dive_log/domain/entities/cylinder_sac.dart';
Expand Down Expand Up @@ -1289,6 +1290,19 @@ class _DiveDetailPageState extends ConsumerState<DiveDetailPage> {
tanks: dive.tanks,
tankPressures: tankPressures,
gasSwitches: gasSwitchesAsync.valueOrNull,
gasSegments:
(dive.tanks.isEmpty || dive.profile.isEmpty)
? null
: buildGasUsageSegments(
tanks: dive.tanks,
gasSwitches:
gasSwitchesAsync.valueOrNull ?? const [],
diveDurationSeconds:
dive.profile.last.timestamp,
),
diveDurationSeconds: dive.profile.isEmpty
? null
: dive.profile.last.timestamp,
computerProfiles: multiComputerProfiles,
visibleComputers: effectiveVisible,
computerLineColors: computerLineColors,
Expand Down Expand Up @@ -4943,6 +4957,21 @@ class _FullscreenProfilePageState
tanks: dive.tanks,
tankPressures: widget.tankPressures,
gasSwitches: widget.gasSwitches,
gasSegments: (dive.tanks.isEmpty || dive.profile.isEmpty)
? null
: buildGasUsageSegments(
tanks: dive.tanks,
gasSwitches: widget.gasSwitches ?? const [],
diveDurationSeconds: dive.profile.last.timestamp,
),
diveDurationSeconds: dive.profile.isEmpty
? null
: dive.profile.last.timestamp,
highlightedTimestamp:
_selectedPointIndex != null &&
_selectedPointIndex! < dive.profile.length
? dive.profile[_selectedPointIndex!].timestamp
: null,
onPointSelected: (index) {
setState(() {
_selectedPoint = index != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ final gasSwitchesProvider =
});

/// Gas type classification for coloring
enum GasType { air, nitrox, trimix }
enum GasType { air, nitrox, oxygen, trimix }

/// Extension to determine gas type from GasSwitchWithTank
extension GasSwitchWithTankGasType on GasSwitchWithTank {
GasType get gasType {
if (isTrimix) return GasType.trimix;
if (isOxygen) return GasType.oxygen;
if (isNitrox) return GasType.nitrox;
return GasType.air;
}
Expand All @@ -27,6 +28,7 @@ extension GasSwitchWithTankGasType on GasSwitchWithTank {
extension GasTypeFromFractions on ({double o2, double he}) {
GasType get gasType {
if (he > 0) return GasType.trimix;
if (o2 >= 0.99) return GasType.oxygen;
if (o2 > 0.22) return GasType.nitrox;
return GasType.air;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ class ProfileLegendState {
// Per-tank pressure visibility (keyed by tank ID)
final Map<String, bool> showTankPressure;

// Gas timeline strip visibility
final bool showGas;

// Collapsible section expanded/collapsed state (session-only)
final Map<String, bool> sectionExpanded;

Expand Down Expand Up @@ -88,6 +91,7 @@ class ProfileLegendState {
this.ttsSource = MetricDataSource.calculated,
this.cnsSource = MetricDataSource.calculated,
this.showTankPressure = const {},
this.showGas = true,
this.sectionExpanded = const {
'overlays': true,
'decompression': true,
Expand Down Expand Up @@ -159,6 +163,7 @@ class ProfileLegendState {
MetricDataSource? ttsSource,
MetricDataSource? cnsSource,
Map<String, bool>? showTankPressure,
bool? showGas,
Map<String, bool>? sectionExpanded,
}) {
return ProfileLegendState(
Expand Down Expand Up @@ -193,6 +198,7 @@ class ProfileLegendState {
ttsSource: ttsSource ?? this.ttsSource,
cnsSource: cnsSource ?? this.cnsSource,
showTankPressure: showTankPressure ?? this.showTankPressure,
showGas: showGas ?? this.showGas,
sectionExpanded: sectionExpanded ?? this.sectionExpanded,
);
}
Expand Down Expand Up @@ -231,6 +237,7 @@ class ProfileLegendState {
ttsSource == other.ttsSource &&
cnsSource == other.cnsSource &&
mapEquals(showTankPressure, other.showTankPressure) &&
showGas == other.showGas &&
mapEquals(sectionExpanded, other.sectionExpanded);

@override
Expand Down Expand Up @@ -264,6 +271,7 @@ class ProfileLegendState {
ttsSource,
cnsSource,
...showTankPressure.entries,
showGas,
...sectionExpanded.entries,
]);
}
Expand Down Expand Up @@ -458,6 +466,11 @@ class ProfileLegend extends _$ProfileLegend {
);
}

/// Toggle visibility of the gas-usage timeline strip below the chart.
void toggleGas() {
state = state.copyWith(showGas: !state.showGas);
}

/// Toggle visibility for a specific tank's pressure line
void toggleTankPressure(String tankId) {
final current = state.showTankPressure[tankId] ?? true;
Expand Down
Loading
Loading