diff --git a/lib/features/dive_log/data/services/gas_usage_segments_service.dart b/lib/features/dive_log/data/services/gas_usage_segments_service.dart new file mode 100644 index 000000000..f06880848 --- /dev/null +++ b/lib/features/dive_log/data/services/gas_usage_segments_service.dart @@ -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 buildGasUsageSegments({ + required List tanks, + required List gasSwitches, + required int diveDurationSeconds, +}) { + if (tanks.isEmpty || diveDurationSeconds <= 0) { + return const []; + } + + 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 = []; + + 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 _mergeAdjacentSameGas(List segments) { + if (segments.length < 2) return segments; + final merged = [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; +} diff --git a/lib/features/dive_log/domain/entities/dive.dart b/lib/features/dive_log/domain/entities/dive.dart index 33d95f797..679e2c5e6 100644 --- a/lib/features/dive_log/domain/entities/dive.dart +++ b/lib/features/dive_log/domain/entities/dive.dart @@ -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'; } diff --git a/lib/features/dive_log/domain/entities/gas_switch.dart b/lib/features/dive_log/domain/entities/gas_switch.dart index c600926df..87b21c8ca 100644 --- a/lib/features/dive_log/domain/entities/gas_switch.dart +++ b/lib/features/dive_log/domain/entities/gas_switch.dart @@ -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; diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 7bc4a1e91..a48672a55 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -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'; @@ -1289,6 +1290,19 @@ class _DiveDetailPageState extends ConsumerState { 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, @@ -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 diff --git a/lib/features/dive_log/presentation/providers/gas_switch_providers.dart b/lib/features/dive_log/presentation/providers/gas_switch_providers.dart index d8ba31a01..aefafaeb4 100644 --- a/lib/features/dive_log/presentation/providers/gas_switch_providers.dart +++ b/lib/features/dive_log/presentation/providers/gas_switch_providers.dart @@ -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; } @@ -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; } diff --git a/lib/features/dive_log/presentation/providers/profile_legend_provider.dart b/lib/features/dive_log/presentation/providers/profile_legend_provider.dart index 0e098d0a7..2488e2b38 100644 --- a/lib/features/dive_log/presentation/providers/profile_legend_provider.dart +++ b/lib/features/dive_log/presentation/providers/profile_legend_provider.dart @@ -55,6 +55,9 @@ class ProfileLegendState { // Per-tank pressure visibility (keyed by tank ID) final Map showTankPressure; + // Gas timeline strip visibility + final bool showGas; + // Collapsible section expanded/collapsed state (session-only) final Map sectionExpanded; @@ -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, @@ -159,6 +163,7 @@ class ProfileLegendState { MetricDataSource? ttsSource, MetricDataSource? cnsSource, Map? showTankPressure, + bool? showGas, Map? sectionExpanded, }) { return ProfileLegendState( @@ -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, ); } @@ -231,6 +237,7 @@ class ProfileLegendState { ttsSource == other.ttsSource && cnsSource == other.cnsSource && mapEquals(showTankPressure, other.showTankPressure) && + showGas == other.showGas && mapEquals(sectionExpanded, other.sectionExpanded); @override @@ -264,6 +271,7 @@ class ProfileLegendState { ttsSource, cnsSource, ...showTankPressure.entries, + showGas, ...sectionExpanded.entries, ]); } @@ -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; diff --git a/lib/features/dive_log/presentation/widgets/dive_profile_chart.dart b/lib/features/dive_log/presentation/widgets/dive_profile_chart.dart index e99cd35f7..04b9369d4 100644 --- a/lib/features/dive_log/presentation/widgets/dive_profile_chart.dart +++ b/lib/features/dive_log/presentation/widgets/dive_profile_chart.dart @@ -1,3477 +1,3627 @@ -import 'dart:math' as math; - -import 'package:fl_chart/fl_chart.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; -import 'package:submersion/core/providers/provider.dart'; - -import 'package:submersion/core/constants/enums.dart'; -import 'package:submersion/core/constants/profile_metrics.dart'; -import 'package:submersion/core/constants/units.dart'; -import 'package:submersion/core/theme/app_colors.dart'; -import 'package:submersion/core/deco/ascent_rate_calculator.dart'; -import 'package:submersion/core/utils/unit_formatter.dart'; -import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; -import 'package:submersion/features/dive_log/data/services/profile_markers_service.dart'; -import 'package:submersion/features/dive_log/presentation/widgets/computer_toggle_bar.dart'; -import 'package:submersion/features/dive_log/domain/entities/dive.dart'; -import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; -import 'package:submersion/features/dive_log/domain/entities/profile_event.dart'; -import 'package:submersion/features/dive_log/presentation/providers/profile_legend_provider.dart'; -import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_legend.dart'; -import 'package:submersion/features/dive_log/presentation/widgets/gas_colors.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; - -/// Structured row emitted via [DiveProfileChart.onTooltipData] so callers -/// can render the tooltip externally (e.g., below the chart). -class TooltipRow { - final String label; - final String value; - final Color bulletColor; - - const TooltipRow({ - required this.label, - required this.value, - required this.bulletColor, - }); -} - -/// Interactive dive profile chart showing depth over time with zoom/pan support -class DiveProfileChart extends ConsumerStatefulWidget { - final List profile; - final Duration? diveDuration; - final double? maxDepth; - final bool showTemperature; - final bool showPressure; - final void Function(int? index)? onPointSelected; - - // Decompression visualization data (optional) - /// Ceiling curve in meters, same length as profile - final List? ceilingCurve; - - /// Ascent rate data for each profile point - final List? ascentRates; - - /// Profile events to display as markers - final List? events; - - /// NDL values in seconds for each point (-1 = in deco) - final List? ndlCurve; - - /// SAC rate curve (bar/min at surface) - smoothed for visualization - final List? sacCurve; - - /// Tank volume in liters (for L/min SAC conversion) - final double? tankVolume; - - /// Normalization factor to align profile SAC with tank-based SAC - final double sacNormalizationFactor; - - /// Whether to show ceiling by default - final bool showCeiling; - - /// Whether to color depth line by ascent rate - final bool showAscentRateColors; - - /// Whether to show event markers - final bool showEvents; - - /// Whether to show SAC curve by default - final bool showSac; - - /// Profile markers to display (max depth, pressure thresholds) - final List? markers; - - /// Whether to show max depth marker (from settings) - final bool showMaxDepthMarker; - - /// Whether to show pressure threshold markers (from settings) - final bool showPressureThresholdMarkers; - - /// Gas switches for coloring profile segments by active gas - final List? gasSwitches; - - /// Tanks for determining initial gas color (before first switch) - final List? tanks; - - /// Per-tank time-series pressure data (keyed by tank ID) - /// Used for multi-tank pressure visualization - final Map>? tankPressures; - - /// Optional key for exporting the chart as an image. - /// When provided, wraps the chart in a RepaintBoundary for screenshot capture. - final GlobalKey? exportKey; - - /// Optional playback cursor timestamp in seconds. - /// When provided, renders a vertical line at this position for step-through playback. - final int? playbackTimestamp; - - /// Optional highlighted timestamp in seconds (e.g. from heat map hover). - /// Renders a subtle vertical line at this position. - final int? highlightedTimestamp; - - // Advanced decompression/gas curves - /// ppO2 curve in bar - final List? ppO2Curve; - - /// ppN2 curve in bar - final List? ppN2Curve; - - /// ppHe curve in bar (for trimix) - final List? ppHeCurve; - - /// MOD curve in meters - final List? modCurve; - - /// Gas density curve in g/L - final List? densityCurve; - - /// Gradient Factor % curve (0-100+) - final List? gfCurve; - - /// Surface GF% curve (0-100+) - final List? surfaceGfCurve; - - /// Mean depth curve in meters - final List? meanDepthCurve; - - /// TTS (Time To Surface) curve in seconds - final List? ttsCurve; - - /// Cumulative CNS% curve (includes residual from prior dives) - final List? cnsCurve; - - /// Cumulative OTU curve - final List? otuCurve; - - // Multi-computer rendering parameters - /// Map of computerId -> profile points for multi-computer rendering. - /// When non-null with 2+ entries, each computer is drawn with its own color. - final Map>? computerProfiles; - - /// Set of currently visible computer IDs. - /// When null, all computers in [computerProfiles] are visible. - final Set? visibleComputers; - - /// Map of computerId -> color for multi-computer rendering. - final Map? computerLineColors; - - /// Set of computer IDs that use a solid line (primaries). - /// Computers not in this set use a dashed line style. - final Set? primaryComputers; - - /// When true, the built-in tooltip is suppressed and tooltip data is - /// emitted via [onTooltipData] so callers can render it externally - /// (e.g., below the chart in the profile panel). - final bool tooltipBelow; - - /// Called with structured tooltip row data when a point is touched - /// and [tooltipBelow] is true. Null clears the tooltip. - final void Function(List? rows)? onTooltipData; - - /// Returns responsive left axis reserved size based on available chart width. - /// Tick labels are plain numbers (e.g. "30", "60") so don't need much space. - static double leftAxisSize(double availableWidth) => - availableWidth < 350 ? 28.0 : 32.0; - - /// Returns responsive right axis reserved size based on available chart width. - /// Needs extra room for 4-digit values like PSI pressure (e.g. "3000"). - static double rightAxisSize(double availableWidth) => - availableWidth < 350 ? 32.0 : 38.0; - - const DiveProfileChart({ - super.key, - required this.profile, - this.diveDuration, - this.maxDepth, - this.showTemperature = true, - this.showPressure = false, - this.onPointSelected, - this.ceilingCurve, - this.ascentRates, - this.events, - this.ndlCurve, - this.sacCurve, - this.tankVolume, - this.sacNormalizationFactor = 1.0, - this.showCeiling = true, - this.showAscentRateColors = true, - this.showEvents = true, - this.showSac = false, - this.markers, - this.showMaxDepthMarker = false, - this.showPressureThresholdMarkers = false, - this.gasSwitches, - this.tanks, - this.tankPressures, - this.exportKey, - this.playbackTimestamp, - this.highlightedTimestamp, - this.ppO2Curve, - this.ppN2Curve, - this.ppHeCurve, - this.modCurve, - this.densityCurve, - this.gfCurve, - this.surfaceGfCurve, - this.meanDepthCurve, - this.ttsCurve, - this.cnsCurve, - this.otuCurve, - this.computerProfiles, - this.visibleComputers, - this.computerLineColors, - this.primaryComputers, - this.tooltipBelow = false, - this.onTooltipData, - }); - - @override - ConsumerState createState() => _DiveProfileChartState(); -} - -class _DiveProfileChartState extends ConsumerState { - bool _showTemperature = true; - - bool _showHeartRate = false; - bool _showSac = false; - - // Per-tank pressure visibility (keyed by tank ID) - // Defaults to all visible; populated on first build if multi-tank data exists - final Map _showTankPressure = {}; - - // Decompression visualization toggles - bool _showCeiling = true; - bool _showAscentRateColors = true; - bool _showEvents = true; - - // Profile marker toggles - bool _showMaxDepthMarkerLocal = true; - bool _showPressureMarkersLocal = true; - - // Gas switch visualization toggle - bool _showGasSwitchMarkers = true; - - // Advanced decompression/gas toggles - bool _showNdl = false; - bool _showPpO2 = false; - bool _showPpN2 = false; - bool _showPpHe = false; - bool _showMod = false; - bool _showDensity = false; - bool _showGf = false; - bool _showSurfaceGf = false; - bool _showMeanDepth = false; - bool _showTts = false; - bool _showCns = false; - bool _showOtu = false; - - // Helper getters for marker availability - bool get _hasMaxDepthMarker => - widget.markers?.any((m) => m.type == ProfileMarkerType.maxDepth) ?? false; - - bool get _hasPressureMarkers => - widget.markers?.any((m) => m.type != ProfileMarkerType.maxDepth) ?? false; - - /// Whether multi-tank pressure data is available - bool get _hasMultiTankPressure => - widget.tankPressures != null && widget.tankPressures!.isNotEmpty; - - /// Get tank by ID for display purposes - DiveTank? _getTankById(String tankId) { - final tanks = widget.tanks; - if (tanks == null) return null; - for (final tank in tanks) { - if (tank.id == tankId) return tank; - } - return null; - } - - /// Sort tank IDs by tank order - List _sortedTankIds(Iterable tankIds) { - final ids = tankIds.toList(); - ids.sort((a, b) { - final orderA = _getTankById(a)?.order ?? 999; - final orderB = _getTankById(b)?.order ?? 999; - return orderA.compareTo(orderB); - }); - return ids; - } - - /// Get color for ascent rate category - Color _getAscentRateColor(AscentRateCategory category) { - switch (category) { - case AscentRateCategory.safe: - return Colors.green; - case AscentRateCategory.warning: - return Colors.orange; - case AscentRateCategory.danger: - return Colors.red; - } - } - - /// Interpolate tank pressure at a given timestamp - double? _interpolateTankPressure( - List points, - int timestamp, - ) { - if (points.isEmpty) return null; - - // Find surrounding points - TankPressurePoint? before; - TankPressurePoint? after; - - for (final point in points) { - if (point.timestamp <= timestamp) { - before = point; - } else { - after = point; - break; - } - } - - // Exact match or only before point - if (before != null && (after == null || before.timestamp == timestamp)) { - return before.pressure; - } - - // Only after point (timestamp before first data point) - if (before == null && after != null) { - return after.pressure; - } - - // Interpolate between before and after - if (before != null && after != null) { - final t = - (timestamp - before.timestamp) / (after.timestamp - before.timestamp); - return before.pressure + (after.pressure - before.pressure) * t; - } - - return null; - } - - /// Get color for tank by index (fallback when no gas mix info) - Color _getTankColor(int index) { - const colors = [ - Colors.orange, - Colors.amber, - Colors.green, - Colors.cyan, - Colors.purple, - Colors.pink, - ]; - return colors[index % colors.length]; - } - - /// Get dash pattern for tank by index - List? _getTankDashPattern(int index) { - switch (index) { - case 0: - return [8, 4]; // Primary: long dash - case 1: - return [4, 4]; // Secondary: medium dash - case 2: - return [2, 2]; // Tertiary: short dash - case 3: - return [8, 2, 2, 2]; // Fourth: dash-dot - default: - return [4, 2]; - } - } - - // Zoom/pan state - double _zoomLevel = 1.0; - double _panOffsetX = 0.0; // Normalized offset (0-1 range based on total data) - double _panOffsetY = 0.0; - - // For gesture handling - double _previousZoom = 1.0; - Offset _previousPan = Offset.zero; - Offset _startFocalPoint = Offset.zero; - - // Zoom limits - static const double _minZoom = 1.0; - static const double _maxZoom = 10.0; - - // Tooltip memoization - int? _lastTooltipSpotIndex; - List _lastTooltipItems = []; - - @override - void initState() { - super.initState(); - _showTemperature = widget.showTemperature; - _showSac = widget.showSac; - _showCeiling = widget.showCeiling; - _showAscentRateColors = widget.showAscentRateColors; - _showEvents = widget.showEvents; - _scheduleTankPressureVisibilityInitialization(); - } - - @override - void didUpdateWidget(covariant DiveProfileChart oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.profile != widget.profile) { - _lastTooltipSpotIndex = null; - _lastTooltipItems = []; - } - if (oldWidget.tankPressures != widget.tankPressures) { - _scheduleTankPressureVisibilityInitialization(); - } - } - - void _scheduleTankPressureVisibilityInitialization() { - if (!_hasMultiTankPressure) return; - final tankIds = widget.tankPressures!.keys.toList(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || tankIds.isEmpty) return; - ref.read(profileLegendProvider.notifier).initializeTankPressures(tankIds); - }); - } - - void _resetZoom() { - setState(() { - _zoomLevel = 1.0; - _panOffsetX = 0.0; - _panOffsetY = 0.0; - }); - } - - /// Build and emit [TooltipRow] data for external rendering when - /// [DiveProfileChart.tooltipBelow] is true. - void _emitExternalTooltip( - List touchedSpots, - UnitFormatter units, - ColorScheme colorScheme, - ) { - if (widget.onTooltipData == null) return; - - final spot = touchedSpots.where((s) => s.barIndex == 0).firstOrNull; - if (spot == null || spot.spotIndex >= widget.profile.length) { - widget.onTooltipData!(null); - return; - } - - final point = widget.profile[spot.spotIndex]; - final rows = []; - final onSurface = colorScheme.onInverseSurface; - - // Time - final minutes = point.timestamp ~/ 60; - final seconds = point.timestamp % 60; - rows.add( - TooltipRow( - label: 'Time', - value: '$minutes:${seconds.toString().padLeft(2, '0')}', - bulletColor: onSurface.withValues(alpha: 0.5), - ), - ); - - // Depth - rows.add( - TooltipRow( - label: 'Depth', - value: units.formatDepth(point.depth), - bulletColor: AppColors.chartDepth, - ), - ); - - // Temperature - if (_showTemperature) { - rows.add( - TooltipRow( - label: 'Temp', - value: point.temperature != null - ? units.formatTemperature(point.temperature) - : '-', - bulletColor: colorScheme.tertiary, - ), - ); - } - - // Ceiling - if (_showCeiling && - widget.ceilingCurve != null && - spot.spotIndex < widget.ceilingCurve!.length) { - final ceiling = widget.ceilingCurve![spot.spotIndex]; - rows.add( - TooltipRow( - label: 'Ceiling', - value: ceiling > 0 ? units.formatDepth(ceiling) : '-', - bulletColor: const Color(0xFFD32F2F), - ), - ); - } - - // Ascent rate - if (_showAscentRateColors && - widget.ascentRates != null && - spot.spotIndex < widget.ascentRates!.length) { - final ascentRate = widget.ascentRates![spot.spotIndex]; - final rate = ascentRate.rateMetersPerMin; - final convertedRate = units.convertDepth(rate.abs()); - String arrow = '-'; - Color rateColor = Colors.grey; - if (rate > 0.5) { - arrow = '\u2191'; - rateColor = ascentRate.category == AscentRateCategory.safe - ? Colors.lime - : _getAscentRateColor(ascentRate.category); - } else if (rate < -0.5) { - arrow = '\u2193'; - rateColor = Colors.cyan; - } - rows.add( - TooltipRow( - label: 'Rate', - value: - '$arrow ${convertedRate.toStringAsFixed(1)} ${units.depthSymbol}/min', - bulletColor: rateColor, - ), - ); - } - - // Heart rate - if (_showHeartRate) { - rows.add( - TooltipRow( - label: 'HR', - value: point.heartRate != null ? '${point.heartRate} bpm' : '-', - bulletColor: Colors.red, - ), - ); - } - - // SAC - if (_showSac && - widget.sacCurve != null && - spot.spotIndex < widget.sacCurve!.length) { - final sacBarPerMin = widget.sacCurve![spot.spotIndex]; - String sacValue = '-'; - if (sacBarPerMin > 0) { - final normalizedSac = sacBarPerMin * widget.sacNormalizationFactor; - final sacUnit = ref.read(settingsProvider).sacUnit; - if (sacUnit == SacUnit.litersPerMin && widget.tankVolume != null) { - final sacLPerMin = normalizedSac * widget.tankVolume!; - sacValue = - '${units.convertVolume(sacLPerMin).toStringAsFixed(1)} ${units.volumeSymbol}/min'; - } else { - sacValue = - '${units.convertPressure(normalizedSac).toStringAsFixed(1)} ${units.pressureSymbol}/min'; - } - } - rows.add( - TooltipRow(label: 'SAC', value: sacValue, bulletColor: Colors.teal), - ); - } - - // NDL - if (_showNdl && - widget.ndlCurve != null && - spot.spotIndex < widget.ndlCurve!.length) { - final ndl = widget.ndlCurve![spot.spotIndex]; - String ndlValue; - if (ndl < 0) { - ndlValue = 'DECO'; - } else if (ndl < 3600) { - final min = ndl ~/ 60; - final sec = ndl % 60; - ndlValue = '$min:${sec.toString().padLeft(2, '0')}'; - } else { - ndlValue = '>60 min'; - } - rows.add( - TooltipRow( - label: 'NDL', - value: ndlValue, - bulletColor: Colors.yellow.shade700, - ), - ); - } - - // ppO2 - if (_showPpO2 && - widget.ppO2Curve != null && - spot.spotIndex < widget.ppO2Curve!.length) { - rows.add( - TooltipRow( - label: 'ppO2', - value: '${widget.ppO2Curve![spot.spotIndex].toStringAsFixed(2)} bar', - bulletColor: const Color(0xFF00ACC1), - ), - ); - } - - // ppN2 - if (_showPpN2 && - widget.ppN2Curve != null && - spot.spotIndex < widget.ppN2Curve!.length) { - rows.add( - TooltipRow( - label: 'ppN2', - value: '${widget.ppN2Curve![spot.spotIndex].toStringAsFixed(2)} bar', - bulletColor: Colors.indigo, - ), - ); - } - - // ppHe - if (_showPpHe && - widget.ppHeCurve != null && - spot.spotIndex < widget.ppHeCurve!.length) { - final ppHe = widget.ppHeCurve![spot.spotIndex]; - if (ppHe > 0.001) { - rows.add( - TooltipRow( - label: 'ppHe', - value: '${ppHe.toStringAsFixed(2)} bar', - bulletColor: Colors.pink.shade300, - ), - ); - } - } - - // MOD - if (_showMod && - widget.modCurve != null && - spot.spotIndex < widget.modCurve!.length) { - final mod = widget.modCurve![spot.spotIndex]; - if (mod > 0 && mod < 200) { - rows.add( - TooltipRow( - label: 'MOD', - value: units.formatDepth(mod), - bulletColor: Colors.deepOrange, - ), - ); - } - } - - // Gas density - if (_showDensity && - widget.densityCurve != null && - spot.spotIndex < widget.densityCurve!.length) { - rows.add( - TooltipRow( - label: 'Density', - value: - '${widget.densityCurve![spot.spotIndex].toStringAsFixed(2)} g/L', - bulletColor: Colors.brown, - ), - ); - } - - // GF% - if (_showGf && - widget.gfCurve != null && - spot.spotIndex < widget.gfCurve!.length) { - rows.add( - TooltipRow( - label: 'GF', - value: '${widget.gfCurve![spot.spotIndex].toStringAsFixed(0)}%', - bulletColor: Colors.deepPurple, - ), - ); - } - - // Surface GF - if (_showSurfaceGf && - widget.surfaceGfCurve != null && - spot.spotIndex < widget.surfaceGfCurve!.length) { - rows.add( - TooltipRow( - label: 'Srf GF', - value: - '${widget.surfaceGfCurve![spot.spotIndex].toStringAsFixed(0)}%', - bulletColor: Colors.purple.shade300, - ), - ); - } - - // Mean depth - if (_showMeanDepth && - widget.meanDepthCurve != null && - spot.spotIndex < widget.meanDepthCurve!.length) { - rows.add( - TooltipRow( - label: 'Mean', - value: units.formatDepth(widget.meanDepthCurve![spot.spotIndex]), - bulletColor: Colors.blueGrey, - ), - ); - } - - // TTS - if (_showTts && - widget.ttsCurve != null && - spot.spotIndex < widget.ttsCurve!.length) { - final tts = widget.ttsCurve![spot.spotIndex]; - rows.add( - TooltipRow( - label: 'TTS', - value: tts > 0 ? '${(tts / 60).ceil()} min' : '0 min', - bulletColor: const Color(0xFFAD1457), - ), - ); - } - - // CNS% - if (_showCns && - widget.cnsCurve != null && - spot.spotIndex < widget.cnsCurve!.length) { - rows.add( - TooltipRow( - label: 'CNS', - value: '${widget.cnsCurve![spot.spotIndex].toStringAsFixed(1)}%', - bulletColor: const Color(0xFFE65100), - ), - ); - } - - // OTU - if (_showOtu && - widget.otuCurve != null && - spot.spotIndex < widget.otuCurve!.length) { - rows.add( - TooltipRow( - label: 'OTU', - value: widget.otuCurve![spot.spotIndex].toStringAsFixed(0), - bulletColor: const Color(0xFF6D4C41), - ), - ); - } - - // Per-tank pressure - if (widget.tankPressures != null) { - final timestamp = point.timestamp; - final sortedTankIds = _sortedTankIds(widget.tankPressures!.keys); - for (var i = 0; i < sortedTankIds.length; i++) { - final tankId = sortedTankIds[i]; - if (!(_showTankPressure[tankId] ?? true)) continue; - final pressurePoints = widget.tankPressures![tankId]; - if (pressurePoints == null || pressurePoints.isEmpty) continue; - final pressure = _interpolateTankPressure(pressurePoints, timestamp); - final tank = _getTankById(tankId); - final color = tank != null - ? GasColors.forGasMix(tank.gasMix) - : _getTankColor(i); - final tankLabel = tank?.name ?? 'Tank ${i + 1}'; - rows.add( - TooltipRow( - label: tankLabel, - value: pressure != null ? units.formatPressure(pressure) : '-', - bulletColor: color, - ), - ); - } - } - - // Marker info (if touching near a marker) - if (widget.markers != null && widget.markers!.isNotEmpty) { - final timestamp = point.timestamp; - const timestampThreshold = 3; - for (final marker in widget.markers!) { - if (marker.type == ProfileMarkerType.maxDepth) { - if (!widget.showMaxDepthMarker || !_showMaxDepthMarkerLocal) continue; - } else { - if (!widget.showPressureThresholdMarkers || - !_showPressureMarkersLocal) { - continue; - } - } - if ((marker.timestamp - timestamp).abs() <= timestampThreshold) { - rows.add( - TooltipRow( - label: 'Marker', - value: marker.chartLabel, - bulletColor: marker.getColor(), - ), - ); - } - } - } - - widget.onTooltipData!(rows); - } - - void _zoomIn() { - setState(() { - _zoomLevel = (_zoomLevel * 1.5).clamp(_minZoom, _maxZoom); - _clampPanOffsets(); - }); - } - - void _zoomOut() { - setState(() { - _zoomLevel = (_zoomLevel / 1.5).clamp(_minZoom, _maxZoom); - _clampPanOffsets(); - }); - } - - void _clampPanOffsets() { - // Calculate maximum allowed pan based on zoom level - final maxPan = 1.0 - (1.0 / _zoomLevel); - _panOffsetX = _panOffsetX.clamp(0.0, maxPan); - _panOffsetY = _panOffsetY.clamp(0.0, maxPan); - } - - @override - Widget build(BuildContext context) { - if (widget.profile.isEmpty) { - return _buildEmptyState(context); - } - - final settings = ref.watch(settingsProvider); - final units = UnitFormatter(settings); - final hasTemperatureData = widget.profile.any((p) => p.temperature != null); - final hasPressureData = _hasMultiTankPressure; - final hasHeartRateData = widget.profile.any((p) => p.heartRate != null); - final colorScheme = Theme.of(context).colorScheme; - - // Watch legend state from provider - final legendState = ref.watch(profileLegendProvider); - - // Sync local state with provider for backward compatibility - // This allows the chart rendering logic to continue using local state - _showTemperature = legendState.showTemperature; - _showHeartRate = legendState.showHeartRate; - _showSac = legendState.showSac; - _showCeiling = legendState.showCeiling; - _showAscentRateColors = legendState.showAscentRateColors; - _showEvents = legendState.showEvents; - _showMaxDepthMarkerLocal = legendState.showMaxDepthMarker; - _showPressureMarkersLocal = legendState.showPressureMarkers; - _showGasSwitchMarkers = legendState.showGasSwitchMarkers; - // Sync advanced deco/gas toggles - _showNdl = legendState.showNdl; - _showPpO2 = legendState.showPpO2; - _showPpN2 = legendState.showPpN2; - _showPpHe = legendState.showPpHe; - _showMod = legendState.showMod; - _showDensity = legendState.showDensity; - _showGf = legendState.showGf; - _showSurfaceGf = legendState.showSurfaceGf; - _showMeanDepth = legendState.showMeanDepth; - _showTts = legendState.showTts; - _showCns = legendState.showCns; - _showOtu = legendState.showOtu; - // Sync per-tank pressure visibility - for (final entry in legendState.showTankPressure.entries) { - _showTankPressure[entry.key] = entry.value; - } - - // Check data availability for advanced curves - final hasNdlData = widget.ndlCurve != null && widget.ndlCurve!.isNotEmpty; - final hasPpO2Data = - widget.ppO2Curve != null && widget.ppO2Curve!.isNotEmpty; - final hasPpN2Data = - widget.ppN2Curve != null && widget.ppN2Curve!.isNotEmpty; - final hasPpHeData = - widget.ppHeCurve != null && widget.ppHeCurve!.any((v) => v > 0.001); - final hasModData = widget.modCurve != null && widget.modCurve!.isNotEmpty; - final hasDensityData = - widget.densityCurve != null && widget.densityCurve!.isNotEmpty; - final hasGfData = widget.gfCurve != null && widget.gfCurve!.isNotEmpty; - final hasSurfaceGfData = - widget.surfaceGfCurve != null && widget.surfaceGfCurve!.isNotEmpty; - final hasMeanDepthData = - widget.meanDepthCurve != null && widget.meanDepthCurve!.isNotEmpty; - final hasTtsData = widget.ttsCurve != null && widget.ttsCurve!.isNotEmpty; - final hasCnsData = widget.cnsCurve != null && widget.cnsCurve!.isNotEmpty; - final hasOtuData = widget.otuCurve != null && widget.otuCurve!.isNotEmpty; - - // Build legend config based on available data - final legendConfig = ProfileLegendConfig( - hasTemperatureData: hasTemperatureData, - hasPressureData: hasPressureData, - hasHeartRateData: hasHeartRateData, - hasSacCurve: widget.sacCurve != null && widget.sacCurve!.isNotEmpty, - hasCeilingCurve: widget.ceilingCurve != null, - hasAscentRates: widget.ascentRates != null, - hasEvents: widget.events != null && widget.events!.isNotEmpty, - hasMaxDepthMarker: widget.showMaxDepthMarker && _hasMaxDepthMarker, - hasPressureMarkers: - widget.showPressureThresholdMarkers && _hasPressureMarkers, - hasGasSwitches: - widget.gasSwitches != null && widget.gasSwitches!.isNotEmpty, - hasMultiTankPressure: _hasMultiTankPressure, - tanks: widget.tanks, - tankPressures: widget.tankPressures, - hasNdlData: hasNdlData, - hasPpO2Data: hasPpO2Data, - hasPpN2Data: hasPpN2Data, - hasPpHeData: hasPpHeData, - hasModData: hasModData, - hasDensityData: hasDensityData, - hasGfData: hasGfData, - hasSurfaceGfData: hasSurfaceGfData, - hasMeanDepthData: hasMeanDepthData, - hasTtsData: hasTtsData, - hasCnsData: hasCnsData, - hasOtuData: hasOtuData, - ); - - return LayoutBuilder( - builder: (context, constraints) { - // Left axis offset = axisNameSize (16, default) + sideTitles reservedSize - final legendLeftPadding = - 16.0 + DiveProfileChart.leftAxisSize(constraints.maxWidth); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Chart header with legend and zoom controls (decluttered) - DiveProfileLegend( - config: legendConfig, - zoomLevel: _zoomLevel, - minZoom: _minZoom, - maxZoom: _maxZoom, - onZoomIn: _zoomIn, - onZoomOut: _zoomOut, - onResetZoom: _resetZoom, - leftPadding: legendLeftPadding, - ), - - // The chart with gesture handling - // Wrapped in RepaintBoundary for PNG export when exportKey is provided - RepaintBoundary( - key: widget.exportKey, - child: SizedBox( - height: 200, - child: _buildInteractiveChart( - context, - units, - hasTemperatureData: hasTemperatureData, - hasPressureData: hasPressureData, - hasHeartRateData: hasHeartRateData, - ), - ), - ), - // Zoom hint - if (_zoomLevel > 1.0) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - context.l10n.diveLog_profile_zoomHint( - _zoomLevel.toStringAsFixed(1), - ), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ); - }, - ); - } - - Widget _buildInteractiveChart( - BuildContext context, - UnitFormatter units, { - required bool hasTemperatureData, - required bool hasPressureData, - required bool hasHeartRateData, - }) { - return LayoutBuilder( - builder: (context, constraints) { - return Semantics( - label: context.l10n.diveLog_profile_semantics_chart, - child: GestureDetector( - onScaleStart: (details) { - _previousZoom = _zoomLevel; - _previousPan = Offset(_panOffsetX, _panOffsetY); - _startFocalPoint = details.localFocalPoint; - }, - onScaleUpdate: (details) { - // Only apply zoom/pan for multi-touch (pinch) gestures. - // Single-finger drag is handled by fl_chart's touchCallback - // without gesture arena disambiguation delay. - if (details.pointerCount < 2) return; - - setState(() { - // Handle zoom - final newZoom = (_previousZoom * details.scale).clamp( - _minZoom, - _maxZoom, - ); - - // Handle pan - final panDelta = details.localFocalPoint - _startFocalPoint; - - // Convert pixel delta to normalized offset based on chart size - final chartWidth = constraints.maxWidth; - final chartHeight = constraints.maxHeight; - - // Only apply pan if zoomed in - if (newZoom > 1.0) { - final normalizedDeltaX = -panDelta.dx / chartWidth / newZoom; - final normalizedDeltaY = -panDelta.dy / chartHeight / newZoom; - - _panOffsetX = (_previousPan.dx + normalizedDeltaX).clamp( - 0.0, - 1.0 - (1.0 / newZoom), - ); - _panOffsetY = (_previousPan.dy + normalizedDeltaY).clamp( - 0.0, - 1.0 - (1.0 / newZoom), - ); - } else { - _panOffsetX = 0.0; - _panOffsetY = 0.0; - } - - _zoomLevel = newZoom; - }); - }, - onDoubleTap: () { - if (_zoomLevel > 1.0) { - _resetZoom(); - } else { - setState(() { - _zoomLevel = 2.0; - }); - } - }, - child: Listener( - onPointerSignal: (event) { - // Handle mouse scroll wheel for zoom - if (event is PointerScrollEvent) { - setState(() { - final scrollDelta = event.scrollDelta.dy; - if (scrollDelta < 0) { - // Scroll up = zoom in - _zoomLevel = (_zoomLevel * 1.1).clamp(_minZoom, _maxZoom); - } else { - // Scroll down = zoom out - _zoomLevel = (_zoomLevel / 1.1).clamp(_minZoom, _maxZoom); - } - _clampPanOffsets(); - }); - } - }, - child: _buildChart( - context, - units, - availableWidth: constraints.maxWidth, - hasTemperatureData: hasTemperatureData, - hasPressureData: hasPressureData, - hasHeartRateData: hasHeartRateData, - ), - ), - ), - ); - }, - ); - } - - Widget _buildEmptyState(BuildContext context) { - return Container( - height: 200, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ExcludeSemantics( - child: Icon( - Icons.show_chart, - size: 48, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), - const SizedBox(height: 8), - Text( - context.l10n.diveLog_profile_emptyState, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } - - Widget _buildChart( - BuildContext context, - UnitFormatter units, { - required double availableWidth, - required bool hasTemperatureData, - required bool hasPressureData, - required bool hasHeartRateData, - }) { - final colorScheme = Theme.of(context).colorScheme; - final sacUnit = ref.read(sacUnitProvider); - const heartRateColor = Colors.red; - - // Calculate full data bounds (all values stored in meters, convert for display) - final totalMaxTime = widget.profile - .map((p) => p.timestamp) - .reduce(math.max) - .toDouble(); - final maxDepthValueMeters = widget.profile - .map((p) => p.depth) - .reduce(math.max); - // Convert to user's preferred depth unit for chart calculations - final maxDepthValueDisplay = units.convertDepth( - widget.maxDepth ?? maxDepthValueMeters, - ); - final totalMaxDepth = maxDepthValueDisplay * 1.1; // Add 10% padding - - // Apply zoom and pan to calculate visible bounds - final visibleRangeX = totalMaxTime / _zoomLevel; - final visibleRangeY = totalMaxDepth / _zoomLevel; - - final visibleMinX = _panOffsetX * totalMaxTime; - final visibleMaxX = visibleMinX + visibleRangeX; - - final visibleMinDepth = _panOffsetY * totalMaxDepth; - final visibleMaxDepth = visibleMinDepth + visibleRangeY; - - // Temperature bounds (if showing) - convert to user's preferred unit - double? minTemp, maxTemp; - if (_showTemperature && hasTemperatureData) { - final temps = widget.profile - .where((p) => p.temperature != null) - .map((p) => units.convertTemperature(p.temperature!)); - if (temps.isNotEmpty) { - minTemp = temps.reduce(math.min) - 1; - maxTemp = temps.reduce(math.max) + 1; - } - } - - // Determine effective right axis metric using settings default and fallback chain. - // getEffectiveRightAxisMetric() returns null when the user chose "None". - final legendNotifier = ref.read(profileLegendProvider.notifier); - final preferredMetric = legendNotifier.getEffectiveRightAxisMetric(); - final effectiveRightAxisMetric = preferredMetric != null - ? _getEffectiveRightAxisMetric(preferredMetric) - : null; - final rightAxisRange = effectiveRightAxisMetric != null - ? _getMetricRange(effectiveRightAxisMetric, units) - : null; - - // Pressure bounds from multi-tank pressure data - double? minPressure, maxPressure; - if (_hasMultiTankPressure && widget.tankPressures != null) { - for (final pressurePoints in widget.tankPressures!.values) { - for (final point in pressurePoints) { - if (minPressure == null || point.pressure < minPressure) { - minPressure = point.pressure - 10; - } - if (maxPressure == null || point.pressure > maxPressure) { - maxPressure = point.pressure + 10; - } - } - } - } - - // Heart rate bounds (if showing) - double? minHR, maxHR; - if (_showHeartRate && hasHeartRateData) { - final hrs = widget.profile - .where((p) => p.heartRate != null) - .map((p) => p.heartRate!.toDouble()); - if (hrs.isNotEmpty) { - minHR = hrs.reduce(math.min) - 5; - maxHR = hrs.reduce(math.max) + 5; - } - } - - // SAC bounds (if showing) - double? minSac, maxSac; - final hasSacData = widget.sacCurve != null && widget.sacCurve!.isNotEmpty; - if (_showSac && hasSacData) { - final sacs = widget.sacCurve!.where((s) => s > 0); - if (sacs.isNotEmpty) { - minSac = 0; // Always start from 0 for SAC - maxSac = sacs.reduce(math.max) * 1.2; // Add 20% headroom - } - } - - return Stack( - children: [ - LineChart( - LineChartData( - minX: visibleMinX, - maxX: visibleMaxX, - minY: -visibleMaxDepth, // Inverted: negative depth at bottom - maxY: -visibleMinDepth, // Surface area at top (inverted) - clipData: - const FlClipData.all(), // Clip data points outside visible area - gridData: FlGridData( - show: true, - drawVerticalLine: true, - horizontalInterval: _calculateDepthInterval(visibleRangeY), - verticalInterval: _calculateTimeInterval(visibleRangeX), - getDrawingHorizontalLine: (value) => FlLine( - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - strokeWidth: 1, - ), - getDrawingVerticalLine: (value) => FlLine( - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - strokeWidth: 1, - ), - ), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - axisNameWidget: Text( - context.l10n.diveLog_profile_axisDepth(units.depthSymbol), - style: Theme.of(context).textTheme.labelSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - sideTitles: SideTitles( - showTitles: true, - reservedSize: DiveProfileChart.leftAxisSize(availableWidth), - interval: _calculateDepthInterval(visibleRangeY), - getTitlesWidget: (value, meta) { - // Suppress interval ticks too close to the min boundary - // (min is the most-negative value = deepest depth). - final interval = _calculateDepthInterval(visibleRangeY); - final distToMin = (value - meta.min).abs(); - if (distToMin > 0 && distToMin < interval * 0.4) { - return const SizedBox.shrink(); - } - // Show positive depth values (negate the negative axis values) - return SideTitleWidget( - meta: meta, - child: Text( - '${(-value).toInt()}', - style: Theme.of(context).textTheme.labelSmall, - maxLines: 1, - ), - ); - }, - ), - ), - bottomTitles: AxisTitles( - axisNameWidget: Text( - context.l10n.diveLog_profile_axisTime, - style: Theme.of(context).textTheme.labelSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - axisNameSize: 14, - sideTitles: SideTitles( - showTitles: true, - reservedSize: 22, - interval: _calculateTimeInterval(visibleRangeX), - getTitlesWidget: (value, meta) { - // Suppress interval ticks that are too close to the max - // boundary to prevent overlapping labels. - final interval = _calculateTimeInterval(visibleRangeX); - final distToMax = (meta.max - value).abs(); - if (distToMax > 0 && distToMax < interval * 0.4) { - return const SizedBox.shrink(); - } - final minutes = (value / 60).round(); - return SideTitleWidget( - meta: meta, - child: Text( - '$minutes', - style: Theme.of(context).textTheme.labelSmall, - maxLines: 1, - ), - ); - }, - ), - ), - rightTitles: AxisTitles( - axisNameWidget: - effectiveRightAxisMetric != null && rightAxisRange != null - ? Text( - _rightAxisLabel(effectiveRightAxisMetric, units), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: effectiveRightAxisMetric.getColor(colorScheme), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - sideTitles: SideTitles( - showTitles: - effectiveRightAxisMetric != null && - rightAxisRange != null, - reservedSize: DiveProfileChart.rightAxisSize(availableWidth), - getTitlesWidget: (value, meta) { - if (effectiveRightAxisMetric == null || - rightAxisRange == null) { - return const SizedBox(); - } - // Suppress interval ticks too close to the min boundary - final interval = _calculateDepthInterval(visibleRangeY); - final distToMin = (value - meta.min).abs(); - if (distToMin > 0 && distToMin < interval * 0.4) { - return const SizedBox.shrink(); - } - // Map from inverted depth axis to the metric value - final metricValue = _mapDepthToMetricValue( - -value, - totalMaxDepth, - rightAxisRange.min, - rightAxisRange.max, - ); - if (metricValue < rightAxisRange.min || - metricValue > rightAxisRange.max) { - return const SizedBox(); - } - final metricColor = effectiveRightAxisMetric.getColor( - colorScheme, - ); - return SideTitleWidget( - meta: meta, - child: Text( - _formatRightAxisValue( - effectiveRightAxisMetric, - metricValue, - units, - ), - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: metricColor), - maxLines: 1, - ), - ); - }, - ), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - ), - borderData: FlBorderData( - show: true, - border: Border.all(color: colorScheme.outlineVariant), - ), - lineBarsData: [ - // Depth line segments (colored by active gas if gas switches exist) - ..._buildGasColoredDepthLines(colorScheme, units), - - // Gas switch markers (if showing and data available) - if (_showGasSwitchMarkers) ..._buildGasSwitchMarkers(units), - - // Temperature line (if showing) - if (_showTemperature && - hasTemperatureData && - minTemp != null && - maxTemp != null) - _buildTemperatureLine( - colorScheme, - totalMaxDepth, - minTemp, - maxTemp, - units, - ), - - // Multi-tank pressure lines (per-tank visibility controlled - // inside _buildMultiTankPressureLines via _showTankPressure) - if (_hasMultiTankPressure) - ..._buildMultiTankPressureLines(totalMaxDepth), - - // Heart rate line (if showing) - if (_showHeartRate && - hasHeartRateData && - minHR != null && - maxHR != null) - _buildHeartRateLine( - heartRateColor, - totalMaxDepth, - minHR, - maxHR, - ), - - // SAC curve line (if showing) - if (_showSac && hasSacData && minSac != null && maxSac != null) - _buildSacLine(totalMaxDepth, minSac, maxSac), - - // Ceiling line (if showing and data available) - if (_showCeiling && widget.ceilingCurve != null) - _buildCeilingLine(units), - - // NDL line (if showing) - if (_showNdl && widget.ndlCurve != null) - _buildNdlLine(totalMaxDepth), - - // ppO2 line (if showing) - if (_showPpO2 && widget.ppO2Curve != null) - _buildPpO2Line(totalMaxDepth), - - // ppN2 line (if showing) - if (_showPpN2 && widget.ppN2Curve != null) - _buildPpN2Line(totalMaxDepth), - - // ppHe line (if showing and has helium data) - if (_showPpHe && - widget.ppHeCurve != null && - widget.ppHeCurve!.any((v) => v > 0.001)) - _buildPpHeLine(totalMaxDepth), - - // MOD line (if showing) - if (_showMod && widget.modCurve != null) _buildModLine(units), - - // Gas density line (if showing) - if (_showDensity && widget.densityCurve != null) - _buildDensityLine(totalMaxDepth), - - // GF% line (if showing) - if (_showGf && widget.gfCurve != null) - _buildGfLine(totalMaxDepth), - - // Surface GF line (if showing) - if (_showSurfaceGf && widget.surfaceGfCurve != null) - _buildSurfaceGfLine(totalMaxDepth), - - // Mean depth line (if showing) - if (_showMeanDepth && widget.meanDepthCurve != null) - _buildMeanDepthLine(units), - - // TTS line (if showing) - if (_showTts && widget.ttsCurve != null) - _buildTtsLine(totalMaxDepth), - - // CNS% curve (if showing) - if (_showCns && widget.cnsCurve != null) - _buildCnsLine(totalMaxDepth), - - // OTU curve (if showing) - if (_showOtu && widget.otuCurve != null) - _buildOtuLine(totalMaxDepth), - - // Profile markers (max depth, pressure thresholds) - ..._buildMarkerLines( - units, - totalMaxDepth, - minPressure: minPressure, - maxPressure: maxPressure, - ), - ], - extraLinesData: ExtraLinesData( - verticalLines: [ - ..._buildPlaybackCursor(colorScheme), - ..._buildHighlightCursor(colorScheme), - if (_showEvents && widget.events != null) - ..._buildEventVerticalLines(colorScheme), - ], - ), - lineTouchData: LineTouchData( - enabled: true, - touchSpotThreshold: 20, - handleBuiltInTouches: true, - touchCallback: (event, response) { - if (widget.onPointSelected != null || - widget.onTooltipData != null) { - if (event is FlPointerExitEvent || - event is FlLongPressEnd || - event is FlTapUpEvent || - event is FlPanEndEvent) { - widget.onPointSelected?.call(null); - if (widget.tooltipBelow) { - widget.onTooltipData?.call(null); - } - } else if (response?.lineBarSpots != null && - response!.lineBarSpots!.isNotEmpty) { - final spot = response.lineBarSpots!.first; - if (spot.barIndex == 0 && - spot.spotIndex < widget.profile.length) { - widget.onPointSelected?.call(spot.spotIndex); - if (widget.tooltipBelow) { - final settings = ref.read(settingsProvider); - final units = UnitFormatter(settings); - _emitExternalTooltip( - response.lineBarSpots!, - units, - Theme.of(context).colorScheme, - ); - } - } - } - } - }, - touchTooltipData: LineTouchTooltipData( - maxContentWidth: 220, - fitInsideHorizontally: true, - fitInsideVertically: false, - showOnTopOfTheChartBoxArea: true, - tooltipMargin: 0, - getTooltipColor: widget.tooltipBelow - ? (_) => Colors.transparent - : (spot) => colorScheme.inverseSurface, - getTooltipItems: (touchedSpots) { - // When tooltipBelow, suppress the visual bubble. - // Tooltip data is emitted via touchCallback instead. - if (widget.tooltipBelow) { - return touchedSpots.map((_) => null).toList(); - } - // Return cached result if the same spot index is touched again - if (touchedSpots.isNotEmpty) { - final firstDepthSpot = touchedSpots - .where((s) => s.barIndex == 0) - .firstOrNull; - if (firstDepthSpot != null && - firstDepthSpot.spotIndex == _lastTooltipSpotIndex) { - return _lastTooltipItems; - } - } - - // Build tooltip showing all enabled metrics for the touched point - // Only process the depth line (barIndex 0) and build combined tooltip - final result = touchedSpots.map((spot) { - final isDepth = spot.barIndex == 0; - if (!isDepth) { - return null; - } - - final point = widget.profile[spot.spotIndex]; - final minutes = point.timestamp ~/ 60; - final seconds = point.timestamp % 60; - - // Build tooltip with all enabled metrics - final lines = []; - - // Text style constants for consistent column layout - final onSurface = colorScheme.onInverseSurface; - final rowStyle = TextStyle( - fontFamily: 'RobotoMono', - fontSize: 14, - color: onSurface, - fontFeatures: const [FontFeature.tabularFigures()], - ); - - const labelWidth = 8; - const valueWidth = 16; - const rowWidth = labelWidth + valueWidth; - final rowFiller = List.filled(rowWidth, '0').join(); - - String clampText(String text, int maxChars) { - if (text.length <= maxChars) { - return text; - } - return text.substring(0, maxChars); - } - - // Helper to add a formatted row with constant width - void addRow( - String label, - String value, - Color bulletColor, { - String bullet = '●', - double bulletSize = 12, - }) { - if (lines.isNotEmpty) { - lines.add(const TextSpan(text: '\n')); - } - lines.add( - TextSpan( - text: '$bullet ', - style: TextStyle( - color: bulletColor, - fontSize: bulletSize, - ), - ), - ); - final labelText = clampText( - label, - labelWidth, - ).padRight(labelWidth); - final valueText = clampText( - value, - valueWidth, - ).padRight(valueWidth); - final rowText = (labelText + valueText).trimRight(); - lines.add(TextSpan(text: rowText, style: rowStyle)); - - final fillerCount = rowWidth - rowText.length; - if (fillerCount > 0) { - lines.add( - TextSpan( - text: rowFiller.substring(0, fillerCount), - style: rowStyle.copyWith(color: Colors.transparent), - ), - ); - } - } - - // Time (always shown) - final timeValue = - '$minutes:${seconds.toString().padLeft(2, '0')}'; - addRow( - context.l10n.diveLog_tooltip_time, - timeValue, - onSurface.withValues(alpha: 0.5), - ); - - // Depth (always shown) - use same color as depth line - addRow( - context.l10n.diveLog_tooltip_depth, - units.formatDepth(point.depth), - AppColors.chartDepth, - ); - - // Temperature (if enabled - always show row) - if (_showTemperature) { - final tempValue = point.temperature != null - ? units.formatTemperature(point.temperature) - : '—'; - addRow( - context.l10n.diveLog_tooltip_temp, - tempValue, - colorScheme.tertiary, - ); - } - - // Heart rate (if enabled - always show row) - if (_showHeartRate) { - final hrValue = point.heartRate != null - ? '${point.heartRate} bpm' - : '—'; - addRow( - context.l10n.diveLog_tooltip_hr, - hrValue, - Colors.red, - ); - } - - // SAC (if enabled - always show row) - if (_showSac) { - String sacValue = '—'; - if (widget.sacCurve != null && - spot.spotIndex < widget.sacCurve!.length) { - final sacBarPerMin = widget.sacCurve![spot.spotIndex]; - if (sacBarPerMin > 0) { - final normalizedSac = - sacBarPerMin * widget.sacNormalizationFactor; - if (sacUnit == SacUnit.litersPerMin && - widget.tankVolume != null) { - final sacLPerMin = - normalizedSac * widget.tankVolume!; - sacValue = - '${units.convertVolume(sacLPerMin).toStringAsFixed(1)} ${units.volumeSymbol}/min'; - } else { - sacValue = - '${units.convertPressure(normalizedSac).toStringAsFixed(1)} ${units.pressureSymbol}/min'; - } - } - } - addRow( - context.l10n.diveLog_tooltip_sac, - sacValue, - Colors.teal, - ); - } - - // Ceiling (if enabled - always show row) - if (_showCeiling) { - String ceilingValue = '—'; - if (widget.ceilingCurve != null && - spot.spotIndex < widget.ceilingCurve!.length) { - final ceiling = widget.ceilingCurve![spot.spotIndex]; - if (ceiling > 0) { - ceilingValue = units.formatDepth(ceiling); - } - } - addRow( - context.l10n.diveLog_tooltip_ceiling, - ceilingValue, - const Color(0xFFD32F2F), - ); - } - - // Ascent rate (if enabled - always show row with fixed format) - // Uses distinct colors that don't conflict with gas colors: - // - Descent: cyan (distinct from air blue) - // - Safe ascent: lime green (distinct from nitrox green) - // - Warning/danger: orange/red (already distinct) - if (_showAscentRateColors) { - Color rateColor = Colors.grey; - String arrow = '—'; - double convertedRate = 0.0; - - if (widget.ascentRates != null && - spot.spotIndex < widget.ascentRates!.length) { - final ascentRate = widget.ascentRates![spot.spotIndex]; - final rate = ascentRate.rateMetersPerMin; - convertedRate = units.convertDepth(rate.abs()); - if (rate > 0.5) { - arrow = '↑'; - // Use lime for safe ascent (distinct from nitrox green) - rateColor = - ascentRate.category == AscentRateCategory.safe - ? Colors.lime - : _getAscentRateColor(ascentRate.category); - } else if (rate < -0.5) { - arrow = '↓'; - // Use cyan for descent (distinct from air blue) - rateColor = Colors.cyan; - } - } - final rateNum = convertedRate - .toStringAsFixed(1) - .padLeft(5); - final rateValue = - '$arrow$rateNum ${units.depthSymbol}/min'; - addRow( - context.l10n.diveLog_tooltip_rate, - rateValue, - rateColor, - ); - } - - // NDL (if enabled) - if (_showNdl) { - String ndlValue = '—'; - if (widget.ndlCurve != null && - spot.spotIndex < widget.ndlCurve!.length) { - final ndl = widget.ndlCurve![spot.spotIndex]; - if (ndl < 0) { - ndlValue = context.l10n.diveLog_playbackStats_deco; - } else if (ndl < 3600) { - final min = ndl ~/ 60; - final sec = ndl % 60; - ndlValue = '$min:${sec.toString().padLeft(2, '0')}'; - } else { - ndlValue = '>60 min'; - } - } - addRow( - context.l10n.diveLog_tooltip_ndl, - ndlValue, - Colors.yellow.shade700, - ); - } - - // ppO2 (if enabled) - if (_showPpO2) { - String ppO2Value = '—'; - if (widget.ppO2Curve != null && - spot.spotIndex < widget.ppO2Curve!.length) { - final ppO2 = widget.ppO2Curve![spot.spotIndex]; - ppO2Value = '${ppO2.toStringAsFixed(2)} bar'; - } - addRow( - context.l10n.diveLog_tooltip_ppO2, - ppO2Value, - const Color(0xFF00ACC1), - ); - } - - // ppN2 (if enabled) - if (_showPpN2) { - String ppN2Value = '—'; - if (widget.ppN2Curve != null && - spot.spotIndex < widget.ppN2Curve!.length) { - final ppN2 = widget.ppN2Curve![spot.spotIndex]; - ppN2Value = '${ppN2.toStringAsFixed(2)} bar'; - } - addRow( - context.l10n.diveLog_tooltip_ppN2, - ppN2Value, - Colors.indigo, - ); - } - - // ppHe (if enabled) - if (_showPpHe) { - String ppHeValue = '—'; - if (widget.ppHeCurve != null && - spot.spotIndex < widget.ppHeCurve!.length) { - final ppHe = widget.ppHeCurve![spot.spotIndex]; - if (ppHe > 0.001) { - ppHeValue = '${ppHe.toStringAsFixed(2)} bar'; - } - } - addRow( - context.l10n.diveLog_tooltip_ppHe, - ppHeValue, - Colors.pink.shade300, - ); - } - - // MOD (if enabled) - if (_showMod) { - String modValue = '—'; - if (widget.modCurve != null && - spot.spotIndex < widget.modCurve!.length) { - final mod = widget.modCurve![spot.spotIndex]; - if (mod > 0 && mod < 200) { - modValue = units.formatDepth(mod); - } - } - addRow( - context.l10n.diveLog_tooltip_mod, - modValue, - Colors.deepOrange, - ); - } - - // Gas density (if enabled) - if (_showDensity) { - String densityValue = '—'; - if (widget.densityCurve != null && - spot.spotIndex < widget.densityCurve!.length) { - final density = widget.densityCurve![spot.spotIndex]; - densityValue = '${density.toStringAsFixed(2)} g/L'; - } - addRow( - context.l10n.diveLog_tooltip_density, - densityValue, - Colors.brown, - ); - } - - // GF% (if enabled) - if (_showGf) { - String gfValue = '—'; - if (widget.gfCurve != null && - spot.spotIndex < widget.gfCurve!.length) { - final gf = widget.gfCurve![spot.spotIndex]; - gfValue = '${gf.toStringAsFixed(0)}%'; - } - addRow( - context.l10n.diveLog_tooltip_gfPercent, - gfValue, - Colors.deepPurple, - ); - } - - // Surface GF (if enabled) - if (_showSurfaceGf) { - String surfaceGfValue = '—'; - if (widget.surfaceGfCurve != null && - spot.spotIndex < widget.surfaceGfCurve!.length) { - final surfaceGf = - widget.surfaceGfCurve![spot.spotIndex]; - surfaceGfValue = '${surfaceGf.toStringAsFixed(0)}%'; - } - addRow( - context.l10n.diveLog_tooltip_srfGf, - surfaceGfValue, - Colors.purple.shade300, - ); - } - - // Mean depth (if enabled) - if (_showMeanDepth) { - String meanDepthValue = '—'; - if (widget.meanDepthCurve != null && - spot.spotIndex < widget.meanDepthCurve!.length) { - final meanDepth = - widget.meanDepthCurve![spot.spotIndex]; - meanDepthValue = units.formatDepth(meanDepth); - } - addRow( - context.l10n.diveLog_tooltip_mean, - meanDepthValue, - Colors.blueGrey, - ); - } - - // TTS (if enabled) - if (_showTts) { - String ttsValue = '—'; - if (widget.ttsCurve != null && - spot.spotIndex < widget.ttsCurve!.length) { - final tts = widget.ttsCurve![spot.spotIndex]; - if (tts > 0) { - final min = (tts / 60).ceil(); - ttsValue = '$min min'; - } else { - ttsValue = '0 min'; - } - } - addRow( - context.l10n.diveLog_tooltip_tts, - ttsValue, - const Color(0xFFAD1457), - ); - } - - // CNS% (if enabled) - if (_showCns) { - String cnsValue = '\u2014'; - if (widget.cnsCurve != null && - spot.spotIndex < widget.cnsCurve!.length) { - final cns = widget.cnsCurve![spot.spotIndex]; - cnsValue = '${cns.toStringAsFixed(1)}%'; - } - addRow( - context.l10n.diveLog_tooltip_cns, - cnsValue, - const Color(0xFFE65100), - ); - } - - // OTU (if enabled) - if (_showOtu) { - String otuValue = '\u2014'; - if (widget.otuCurve != null && - spot.spotIndex < widget.otuCurve!.length) { - final otu = widget.otuCurve![spot.spotIndex]; - otuValue = otu.toStringAsFixed(0); - } - addRow( - context.l10n.diveLog_tooltip_otu, - otuValue, - const Color(0xFF6D4C41), - ); - } - - // Per-tank pressure (if any tanks are enabled) - if (widget.tankPressures != null) { - final timestamp = point.timestamp; - final sortedTankIds = _sortedTankIds( - widget.tankPressures!.keys, - ); - - for (var i = 0; i < sortedTankIds.length; i++) { - final tankId = sortedTankIds[i]; - if (!(_showTankPressure[tankId] ?? true)) continue; - - final pressurePoints = widget.tankPressures![tankId]; - if (pressurePoints == null || pressurePoints.isEmpty) { - continue; - } - - final pressure = _interpolateTankPressure( - pressurePoints, - timestamp, - ); - final tank = _getTankById(tankId); - final color = tank != null - ? GasColors.forGasMix(tank.gasMix) - : _getTankColor(i); - final tankLabel = - tank?.name ?? - context.l10n.diveLog_tank_title(i + 1); - final pressValue = pressure != null - ? units.formatPressure(pressure) - : '—'; - addRow(tankLabel, pressValue, color); - } - } - - // Marker info (if touching near a marker) - final markers = widget.markers; - if (markers != null && markers.isNotEmpty) { - final timestamp = point.timestamp; - const timestampThreshold = 3; - - for (final marker in markers) { - if (marker.type == ProfileMarkerType.maxDepth) { - if (!widget.showMaxDepthMarker || - !_showMaxDepthMarkerLocal) { - continue; - } - } else { - if (!widget.showPressureThresholdMarkers || - !_showPressureMarkersLocal) { - continue; - } - } - - if ((marker.timestamp - timestamp).abs() <= - timestampThreshold) { - final markerColor = marker.getColor(); - addRow( - context.l10n.diveLog_tooltip_marker, - marker.chartLabel, - markerColor, - bullet: '◆', - bulletSize: 10, - ); - } - } - } - - return LineTooltipItem( - '', // Empty base text, using children instead - TextStyle(color: onSurface), - children: lines, - textAlign: TextAlign.start, - ); - }).toList(); - - // Cache the result for next frame - final depthSpot = touchedSpots - .where((s) => s.barIndex == 0) - .firstOrNull; - if (depthSpot != null) { - _lastTooltipSpotIndex = depthSpot.spotIndex; - _lastTooltipItems = result; - } - - return result; - }, - ), - ), - ), - ), - // Right axis tap overlay for metric selection - if (effectiveRightAxisMetric != null) - Positioned( - right: 0, - top: 0, - bottom: 30, // Leave space for bottom axis - width: 50, // Match reservedSize of right axis - child: Semantics( - button: true, - label: context.l10n.diveLog_profile_semantics_changeRightAxis, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => _showRightAxisMetricSelector( - context, - colorScheme, - effectiveRightAxisMetric, - ), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container(color: Colors.transparent), - ), - ), - ), - ), - ], - ); - } - - /// Show popup menu for selecting right axis metric - void _showRightAxisMetricSelector( - BuildContext context, - ColorScheme colorScheme, - ProfileRightAxisMetric currentMetric, - ) { - final legendNotifier = ref.read(profileLegendProvider.notifier); - - // Build list of metrics grouped by category - final menuItems = >[]; - - // Add "None" option to hide the axis. - // Use onTap instead of relying on the menu return value, because - // showMenu returns null both for "None" (value: null) and for - // dismissing the menu — we can't distinguish them otherwise. - menuItems.add( - PopupMenuItem( - value: null, - onTap: () => legendNotifier.hideRightAxis(), - child: Row( - children: [ - Icon( - Icons.visibility_off, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text(context.l10n.diveLog_profile_rightAxis_none), - ], - ), - ), - ); - menuItems.add(const PopupMenuDivider()); - - // Group metrics by category - for (final category in ProfileMetricCategory.values) { - final metricsInCategory = category.metrics; - final availableMetrics = metricsInCategory - .where((m) => _hasDataForMetric(m)) - .toList(); - - if (availableMetrics.isEmpty) continue; - - // Add divider before category (except first) - if (menuItems.length > 2) { - menuItems.add(const PopupMenuDivider()); - } - - // Add category header - menuItems.add( - PopupMenuItem( - enabled: false, - height: 32, - child: Text( - category.displayName, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - - // Add metrics in this category - for (final metric in availableMetrics) { - final isSelected = metric == currentMetric; - final metricColor = metric.getColor(colorScheme); - - menuItems.add( - PopupMenuItem( - value: metric, - child: Row( - children: [ - Icon( - isSelected ? Icons.check : Icons.show_chart, - size: 16, - color: isSelected - ? metricColor - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Container( - width: 12, - height: 3, - decoration: BoxDecoration( - color: metricColor, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - Text( - metric.displayName, - style: TextStyle( - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ], - ), - ), - ); - } - } - - // Show the popup menu - final RenderBox renderBox = context.findRenderObject() as RenderBox; - final offset = renderBox.localToGlobal(Offset.zero); - - showMenu( - context: context, - position: RelativeRect.fromLTRB( - offset.dx + renderBox.size.width - 200, - offset.dy, - offset.dx + renderBox.size.width, - offset.dy + renderBox.size.height, - ), - items: menuItems, - ).then((selectedMetric) { - // "None" is handled via onTap on its PopupMenuItem. - // Here we only handle actual metric selections (non-null). - if (selectedMetric != null) { - legendNotifier.setRightAxisMetric(selectedMetric); - } - }); - } - - /// Build depth line segments. - /// - /// When [widget.computerProfiles] is provided with 2+ entries, draws one - /// depth curve per visible computer using its assigned color. Primary - /// computers get a solid line; secondaries get a dashed line. - /// Falls back to single-profile rendering when multi-computer data is absent. - List _buildGasColoredDepthLines( - ColorScheme colorScheme, - UnitFormatter units, - ) { - final cpProfiles = widget.computerProfiles; - if (cpProfiles != null && cpProfiles.length >= 2) { - return _buildMultiComputerDepthLines(cpProfiles, units); - } - const depthColor = AppColors.chartDepth; - return [ - _buildSingleDepthSegment( - depthColor, - units, - 0, - widget.profile.length, - showFill: true, - ), - ]; - } - - /// Build one depth line per computer for multi-computer rendering. - List _buildMultiComputerDepthLines( - Map> cpProfiles, - UnitFormatter units, - ) { - final lines = []; - var index = 0; - for (final entry in cpProfiles.entries) { - final computerId = entry.key; - final points = entry.value; - - // Skip computers that have been toggled off. - final visible = widget.visibleComputers; - if (visible != null && !visible.contains(computerId)) { - index++; - continue; - } - - final color = - widget.computerLineColors?[computerId] ?? _computerColorAt(index); - final isPrimary = - widget.primaryComputers?.contains(computerId) ?? index == 0; - - final spots = points - .map( - (p) => FlSpot(p.timestamp.toDouble(), -units.convertDepth(p.depth)), - ) - .toList(); - - if (isPrimary) { - // Solid line with fill for the primary computer. - lines.add( - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: color, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: GasColors.gradientColors(color), - ), - ), - ), - ); - } else { - // Dashed line (no fill) for secondary computers. - lines.add( - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: color, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: const [6, 4], - belowBarData: BarAreaData(show: false), - ), - ); - } - - index++; - } - return lines; - } - - /// Returns a color for a computer at the given index. - /// Delegates to the shared [computerColorAt] in computer_toggle_bar.dart. - Color _computerColorAt(int index) => computerColorAt(index); - - /// Build a single depth line segment with the given color - LineChartBarData _buildSingleDepthSegment( - Color color, - UnitFormatter units, - int startIndex, - int endIndex, { - bool showFill = false, - }) { - return LineChartBarData( - spots: widget.profile - .sublist(startIndex, endIndex) - .map( - (p) => FlSpot(p.timestamp.toDouble(), -units.convertDepth(p.depth)), - ) - .toList(), - isCurved: true, - curveSmoothness: 0.2, - color: color, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: showFill - ? BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: GasColors.gradientColors(color), - ), - ) - : BarAreaData(show: false), - ); - } - - /// Build gas switch marker dots on the profile - List _buildGasSwitchMarkers(UnitFormatter units) { - final gasSwitches = widget.gasSwitches; - if (gasSwitches == null || gasSwitches.isEmpty) { - return []; - } - - return gasSwitches.map((gs) { - final color = GasColors.forMixFraction(gs.o2Fraction, gs.heFraction); - - // Find the depth at this timestamp from profile - final depth = gs.depth ?? _findDepthAtTimestamp(gs.timestamp); - - return LineChartBarData( - spots: [FlSpot(gs.timestamp.toDouble(), -units.convertDepth(depth))], - isCurved: false, - color: Colors.transparent, - barWidth: 0, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, bar, index) { - return FlDotCirclePainter( - radius: 6, - color: color, - strokeWidth: 2, - strokeColor: Colors.white, - ); - }, - ), - ); - }).toList(); - } - - /// Find the depth at a given timestamp by interpolating profile data - double _findDepthAtTimestamp(int timestamp) { - if (widget.profile.isEmpty) return 0; - - // Find the closest profile point - for (int i = 0; i < widget.profile.length; i++) { - if (widget.profile[i].timestamp >= timestamp) { - if (i == 0) return widget.profile[0].depth; - // Simple interpolation - final prev = widget.profile[i - 1]; - final curr = widget.profile[i]; - final ratio = - (timestamp - prev.timestamp) / (curr.timestamp - prev.timestamp); - return prev.depth + (curr.depth - prev.depth) * ratio; - } - } - return widget.profile.last.depth; - } - - LineChartBarData _buildTemperatureLine( - ColorScheme colorScheme, - double chartMaxDepth, - double minTemp, - double maxTemp, - UnitFormatter units, - ) { - return LineChartBarData( - spots: widget.profile - .where((p) => p.temperature != null) - .map( - (p) => FlSpot( - p.timestamp.toDouble(), - // Convert temp to user's unit, then map to depth axis - -_mapTempToDepth( - units.convertTemperature(p.temperature!), - chartMaxDepth, - minTemp, - maxTemp, - ), - ), - ) - .toList(), - isCurved: true, - curveSmoothness: 0.2, - color: colorScheme.tertiary, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [5, 3], - ); - } - - /// Build multiple pressure lines for multi-tank visualization - List _buildMultiTankPressureLines(double chartMaxDepth) { - if (!_hasMultiTankPressure) return []; - - final tankPressures = widget.tankPressures!; - final lines = []; - - // Calculate global min/max pressure across all tanks for consistent scaling - double? globalMinPressure; - double? globalMaxPressure; - - for (final pressurePoints in tankPressures.values) { - for (final point in pressurePoints) { - if (globalMinPressure == null || point.pressure < globalMinPressure) { - globalMinPressure = point.pressure; - } - if (globalMaxPressure == null || point.pressure > globalMaxPressure) { - globalMaxPressure = point.pressure; - } - } - } - - if (globalMinPressure == null || globalMaxPressure == null) return []; - - // Add some padding to the pressure range - final pressureRange = globalMaxPressure - globalMinPressure; - final minPressure = globalMinPressure - (pressureRange * 0.05); - final maxPressure = globalMaxPressure + (pressureRange * 0.05); - - final sortedTankIds = _sortedTankIds(tankPressures.keys); - - // Build a line for each visible tank - for (var i = 0; i < sortedTankIds.length; i++) { - final tankId = sortedTankIds[i]; - - // Skip if tank is hidden - if (_showTankPressure[tankId] == false) continue; - - final pressurePoints = tankPressures[tankId]!; - if (pressurePoints.isEmpty) continue; - - // Get tank for color - final tank = _getTankById(tankId); - - // Use gas color or fallback - final color = tank != null - ? GasColors.forGasMix(tank.gasMix) - : _getTankColor(i); - final dashPattern = _getTankDashPattern(i); - - lines.add( - LineChartBarData( - spots: pressurePoints - .map( - (p) => FlSpot( - p.timestamp.toDouble(), - -_mapValueToDepth( - p.pressure, - chartMaxDepth, - minPressure, - maxPressure, - ), - ), - ) - .toList(), - isCurved: true, - curveSmoothness: 0.2, - color: color, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: dashPattern, - ), - ); - } - - return lines; - } - - LineChartBarData _buildHeartRateLine( - Color color, - double chartMaxDepth, - double minHR, - double maxHR, - ) { - return LineChartBarData( - spots: widget.profile - .where((p) => p.heartRate != null) - .map( - (p) => FlSpot( - p.timestamp.toDouble(), - -_mapValueToDepth( - p.heartRate!.toDouble(), - chartMaxDepth, - minHR, - maxHR, - ), - ), - ) - .toList(), - isCurved: true, - curveSmoothness: 0.2, - color: color, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [3, 2], - ); - } - - /// Build SAC (Surface Air Consumption) curve line - LineChartBarData _buildSacLine( - double chartMaxDepth, - double minSac, - double maxSac, - ) { - const sacColor = Colors.teal; - final sacCurve = widget.sacCurve!; - - // Build spots for each profile point that has SAC data - final spots = []; - for (int i = 0; i < widget.profile.length && i < sacCurve.length; i++) { - final sac = sacCurve[i]; - if (sac > 0) { - spots.add( - FlSpot( - widget.profile[i].timestamp.toDouble(), - -_mapValueToDepth(sac, chartMaxDepth, minSac, maxSac), - ), - ); - } - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: sacColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [6, 3], // Distinctive dash pattern for SAC - ); - } - - // Map temperature value to depth axis for overlay - double _mapTempToDepth( - double temp, - double maxDepth, - double minTemp, - double maxTemp, - ) { - final normalized = (temp - minTemp) / (maxTemp - minTemp); - return maxDepth * (1 - normalized); // Higher temp maps to shallower depth - } - - // Generic value to depth axis mapping - double _mapValueToDepth( - double value, - double maxDepth, - double minValue, - double maxValue, - ) { - final normalized = (value - minValue) / (maxValue - minValue); - return maxDepth * (1 - normalized); - } - - double _calculateDepthInterval(double maxDepth) { - if (maxDepth <= 10) return 2; - if (maxDepth <= 20) return 5; - if (maxDepth <= 50) return 10; - return 20; - } - - double _calculateTimeInterval(double maxTime) { - final minutes = maxTime / 60; - if (minutes <= 10) return 60; // 1 min intervals - if (minutes <= 30) return 300; // 5 min intervals - if (minutes <= 60) return 600; // 10 min intervals - return 900; // 15 min intervals - } - - /// Build the ceiling line (decompression ceiling) - LineChartBarData _buildCeilingLine(UnitFormatter units) { - final ceilingData = widget.ceilingCurve!; - const ceilingColor = Color( - 0xFFD32F2F, - ); // Red 700 - distinct from pressure orange - - // Build spots only where ceiling > 0 - final spots = []; - for (int i = 0; i < widget.profile.length && i < ceilingData.length; i++) { - final ceiling = ceilingData[i]; - if (ceiling > 0) { - spots.add( - FlSpot( - widget.profile[i].timestamp.toDouble(), - -units.convertDepth( - ceiling, - ), // Convert and negate for inverted axis - ), - ); - } - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: ceilingColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [4, 4], - belowBarData: BarAreaData( - show: true, - color: ceilingColor.withValues(alpha: 0.15), - cutOffY: 0, // Fill to surface - applyCutOffY: true, - ), - ); - } - - /// Build NDL (No Decompression Limit) line - /// NDL values are in seconds; shows time remaining before deco obligation - LineChartBarData _buildNdlLine(double chartMaxDepth) { - final ndlData = widget.ndlCurve!; - final ndlColor = Colors.yellow.shade700; - - // Map NDL to chart: max NDL (~60 min) at top, 0 at bottom - const maxNdlSeconds = 3600.0; // 60 minutes as max display - - final spots = []; - for (int i = 0; i < widget.profile.length && i < ndlData.length; i++) { - // Clamp NDL to display range to avoid gaps that cause Bezier artifacts. - // Negative values (in deco) clamp to 0; values > 60 min clamp to 60 min. - final ndl = ndlData[i].clamp(0, maxNdlSeconds.toInt()).toDouble(); - final normalized = ndl / maxNdlSeconds; - final yValue = chartMaxDepth * (1 - normalized); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - preventCurveOverShooting: true, - color: ndlColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [6, 3], - ); - } - - /// Build ppO2 (partial pressure of oxygen) line - /// Values typically range from 0.21 (surface air) to 1.6+ (critical) - LineChartBarData _buildPpO2Line(double chartMaxDepth) { - final ppO2Data = widget.ppO2Curve!; - const ppO2Color = Color(0xFF00ACC1); // Cyan 600 - distinct from depth blue - - // Map ppO2 to chart: 0 at top, 2.0 bar at bottom - const minPpO2 = 0.0; - const maxPpO2 = 2.0; - - final spots = []; - for (int i = 0; i < widget.profile.length && i < ppO2Data.length; i++) { - final ppO2 = ppO2Data[i].clamp(minPpO2, maxPpO2); - final yValue = _mapValueToDepth(ppO2, chartMaxDepth, minPpO2, maxPpO2); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: ppO2Color, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [5, 3], - ); - } - - /// Build ppN2 (partial pressure of nitrogen) line - LineChartBarData _buildPpN2Line(double chartMaxDepth) { - final ppN2Data = widget.ppN2Curve!; - const ppN2Color = Colors.indigo; - - // Map ppN2 to chart: 0 at top, ~5 bar at bottom (deep dive) - const minPpN2 = 0.0; - const maxPpN2 = 5.0; - - final spots = []; - for (int i = 0; i < widget.profile.length && i < ppN2Data.length; i++) { - final ppN2 = ppN2Data[i].clamp(minPpN2, maxPpN2); - final yValue = _mapValueToDepth(ppN2, chartMaxDepth, minPpN2, maxPpN2); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: ppN2Color, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [4, 2], - ); - } - - /// Build ppHe (partial pressure of helium) line for trimix dives - LineChartBarData _buildPpHeLine(double chartMaxDepth) { - final ppHeData = widget.ppHeCurve!; - final ppHeColor = Colors.pink.shade300; - - // Map ppHe to chart: 0 at top, ~3 bar at bottom - const minPpHe = 0.0; - const maxPpHe = 3.0; - - final spots = []; - for (int i = 0; i < widget.profile.length && i < ppHeData.length; i++) { - final ppHe = ppHeData[i]; - if (ppHe > 0.001) { - final clamped = ppHe.clamp(minPpHe, maxPpHe); - final yValue = _mapValueToDepth( - clamped, - chartMaxDepth, - minPpHe, - maxPpHe, - ); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: ppHeColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [3, 3], - ); - } - - /// Build MOD (Maximum Operating Depth) line - /// Shows the MOD limit as a horizontal reference line - LineChartBarData _buildModLine(UnitFormatter units) { - final modData = widget.modCurve!; - const modColor = Colors.deepOrange; - - // MOD is typically constant for a given gas - final spots = []; - for (int i = 0; i < widget.profile.length && i < modData.length; i++) { - final mod = modData[i]; - if (mod > 0 && mod < 200) { - spots.add( - FlSpot( - widget.profile[i].timestamp.toDouble(), - -units.convertDepth(mod), - ), - ); - } - } - - return LineChartBarData( - spots: spots, - isCurved: false, - color: modColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [8, 4], - ); - } - - /// Build gas density line (g/L) - /// High density (>5.7 g/L) increases work of breathing - LineChartBarData _buildDensityLine(double chartMaxDepth) { - final densityData = widget.densityCurve!; - const densityColor = Colors.brown; - - // Map density to chart: 0 at top, 8 g/L at bottom - const minDensity = 0.0; - const maxDensity = 8.0; - - final spots = []; - for (int i = 0; i < widget.profile.length && i < densityData.length; i++) { - final density = densityData[i].clamp(minDensity, maxDensity); - final yValue = _mapValueToDepth( - density, - chartMaxDepth, - minDensity, - maxDensity, - ); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: densityColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [5, 2], - ); - } - - /// Build GF% (Gradient Factor percentage) line at current depth - /// Shows how close tissues are to M-value limit - LineChartBarData _buildGfLine(double chartMaxDepth) { - final gfData = widget.gfCurve!; - const gfColor = Colors.deepPurple; - - // Map GF% to chart: 0% at top, 120% at bottom - const minGf = 0.0; - const maxGf = 120.0; - - final spots = []; - for (int i = 0; i < widget.profile.length && i < gfData.length; i++) { - final gf = gfData[i].clamp(minGf, maxGf); - final yValue = _mapValueToDepth(gf, chartMaxDepth, minGf, maxGf); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: gfColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [4, 3], - ); - } - - /// Build Surface GF% line (what GF would be if surfaced now) - /// Values >100% indicate deco obligation - LineChartBarData _buildSurfaceGfLine(double chartMaxDepth) { - final surfaceGfData = widget.surfaceGfCurve!; - final surfaceGfColor = Colors.purple.shade300; - - // Map Surface GF% to chart: 0% at top, 150% at bottom - const minGf = 0.0; - const maxGf = 150.0; - - final spots = []; - for ( - int i = 0; - i < widget.profile.length && i < surfaceGfData.length; - i++ - ) { - final gf = surfaceGfData[i].clamp(minGf, maxGf); - final yValue = _mapValueToDepth(gf, chartMaxDepth, minGf, maxGf); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: surfaceGfColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [6, 2], - ); - } - - /// Build mean depth line (running average from start) - LineChartBarData _buildMeanDepthLine(UnitFormatter units) { - final meanDepthData = widget.meanDepthCurve!; - const meanDepthColor = Colors.blueGrey; - - final spots = []; - for ( - int i = 0; - i < widget.profile.length && i < meanDepthData.length; - i++ - ) { - spots.add( - FlSpot( - widget.profile[i].timestamp.toDouble(), - -units.convertDepth(meanDepthData[i]), - ), - ); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: meanDepthColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [3, 4], - ); - } - - /// Build TTS (Time To Surface) line - /// Shows total time including deco stops to reach surface - LineChartBarData _buildTtsLine(double chartMaxDepth) { - final ttsData = widget.ttsCurve!; - const ttsColor = Color( - 0xFFAD1457, - ); // Pink 800 - distinct from pressure orange - - // Map TTS to chart: 0 at top, 60 min at bottom - const maxTtsSeconds = 3600.0; - - final spots = []; - for (int i = 0; i < widget.profile.length && i < ttsData.length; i++) { - final tts = ttsData[i].toDouble().clamp(0, maxTtsSeconds); - final normalized = tts / maxTtsSeconds; - final yValue = chartMaxDepth * (1 - normalized); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: ttsColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [5, 4], - ); - } - - /// Compute dynamic max scale for CNS curve based on actual data. - double _getCnsMaxScale() { - if (widget.cnsCurve == null || widget.cnsCurve!.isEmpty) return 100.0; - final actualMax = widget.cnsCurve!.reduce(math.max); - return math.max(actualMax * 1.25, 10.0); // 25% headroom, min 10% - } - - /// Compute dynamic max scale for OTU curve based on actual data. - double _getOtuMaxScale() { - if (widget.otuCurve == null || widget.otuCurve!.isEmpty) return 100.0; - final actualMax = widget.otuCurve!.reduce(math.max); - return math.max(actualMax * 1.25, 20.0); // 25% headroom, min 20 OTU - } - - /// Build cumulative CNS% line - LineChartBarData _buildCnsLine(double chartMaxDepth) { - final cnsData = widget.cnsCurve!; - const cnsColor = Color(0xFFE65100); // Orange 900 - - const minCns = 0.0; - final maxCns = _getCnsMaxScale(); - - final spots = []; - for (int i = 0; i < widget.profile.length && i < cnsData.length; i++) { - final cns = cnsData[i].clamp(minCns, maxCns); - final yValue = _mapValueToDepth(cns, chartMaxDepth, minCns, maxCns); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: cnsColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [6, 3], - ); - } - - /// Build cumulative OTU line - LineChartBarData _buildOtuLine(double chartMaxDepth) { - final otuData = widget.otuCurve!; - const otuColor = Color(0xFF6D4C41); // Brown 600 - - const minOtu = 0.0; - final maxOtu = _getOtuMaxScale(); - - final spots = []; - for (int i = 0; i < widget.profile.length && i < otuData.length; i++) { - final otu = otuData[i].clamp(minOtu, maxOtu); - final yValue = _mapValueToDepth(otu, chartMaxDepth, minOtu, maxOtu); - spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); - } - - return LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.2, - color: otuColor, - barWidth: 2, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - dashArray: [4, 4], - ); - } - - /// Build vertical line for playback cursor - List _buildPlaybackCursor(ColorScheme colorScheme) { - final timestamp = widget.playbackTimestamp; - if (timestamp == null) { - return []; - } - - // Convert timestamp to x position (seconds) - final xPosition = timestamp.toDouble(); - - return [ - VerticalLine( - x: xPosition, - color: colorScheme.primary, - strokeWidth: 2, - dashArray: [4, 4], - label: VerticalLineLabel( - show: true, - alignment: Alignment.topCenter, - padding: const EdgeInsets.only(bottom: 4), - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontSize: 10, - fontWeight: FontWeight.bold, - backgroundColor: colorScheme.primaryContainer.withValues( - alpha: 0.9, - ), - ), - labelResolver: (line) { - final minutes = timestamp ~/ 60; - final seconds = timestamp % 60; - return ' ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')} '; - }, - ), - ), - ]; - } - - /// Build vertical line for external highlight (e.g. heat map hover) - List _buildHighlightCursor(ColorScheme colorScheme) { - final timestamp = widget.highlightedTimestamp; - if (timestamp == null) { - return []; - } - - return [ - VerticalLine( - x: timestamp.toDouble(), - color: colorScheme.onSurface.withValues(alpha: 0.5), - strokeWidth: 1, - dashArray: [3, 3], - ), - ]; - } - - /// Build vertical lines for event markers on the dive profile. - /// - /// Groups events by timestamp and shows only the most severe event at each - /// timestamp to avoid overlapping labels. Lines are colored by severity: - /// info = primary, warning = orange, alert = red. - List _buildEventVerticalLines(ColorScheme colorScheme) { - final events = widget.events; - if (events == null || events.isEmpty) return []; - - // Group events by timestamp, keeping only the most severe at each time - final byTimestamp = {}; - for (final event in events) { - final existing = byTimestamp[event.timestamp]; - if (existing == null || event.severity.index > existing.severity.index) { - byTimestamp[event.timestamp] = event; - } - } - - return byTimestamp.values.map((event) { - final color = _eventSeverityColor(event.severity, colorScheme); - return VerticalLine( - x: event.timestamp.toDouble(), - color: color, - strokeWidth: 1, - dashArray: [3, 3], - label: VerticalLineLabel( - show: true, - alignment: Alignment.topCenter, - padding: const EdgeInsets.only(bottom: 2), - style: TextStyle( - color: color, - fontSize: 9, - backgroundColor: colorScheme.surface.withValues(alpha: 0.8), - ), - labelResolver: (line) => event.displayName, - ), - ); - }).toList(); - } - - /// Returns the color for an event based on its severity level. - Color _eventSeverityColor(EventSeverity severity, ColorScheme colorScheme) { - switch (severity) { - case EventSeverity.info: - return colorScheme.primary.withValues(alpha: 0.5); - case EventSeverity.warning: - return Colors.orange; - case EventSeverity.alert: - return Colors.red; - } - } - - /// Build marker lines for max depth and pressure thresholds - List _buildMarkerLines( - UnitFormatter units, - double chartMaxDepth, { - double? minPressure, - double? maxPressure, - }) { - final lines = []; - final markers = widget.markers; - - if (markers == null || markers.isEmpty) return lines; - - for (final marker in markers) { - // Skip max depth markers if setting is off or locally toggled off - if (marker.type == ProfileMarkerType.maxDepth) { - if (!widget.showMaxDepthMarker || !_showMaxDepthMarkerLocal) continue; - } else { - // Skip pressure markers if setting is off or locally toggled off - if (!widget.showPressureThresholdMarkers || - !_showPressureMarkersLocal) { - continue; - } - } - - lines.add( - _buildSingleMarkerLine( - marker, - units, - chartMaxDepth, - minPressure: minPressure, - maxPressure: maxPressure, - ), - ); - } - - return lines; - } - - /// Build a single marker as a LineChartBarData with a visible dot - LineChartBarData _buildSingleMarkerLine( - ProfileMarker marker, - UnitFormatter units, - double chartMaxDepth, { - double? minPressure, - double? maxPressure, - }) { - final color = marker.getColor(); - final size = marker.markerSize; - - // Calculate Y position based on marker type - double yPosition; - if (marker.type == ProfileMarkerType.maxDepth) { - // Max depth marker: position on depth line - yPosition = -units.convertDepth(marker.depth); - } else { - // Pressure threshold marker: position on pressure line - // Use the threshold pressure value (marker.value) mapped to the chart's Y axis - if (minPressure != null && maxPressure != null && marker.value != null) { - yPosition = -_mapValueToDepth( - marker.value!, - chartMaxDepth, - minPressure, - maxPressure, - ); - } else { - // Fallback to depth position if pressure range not available - yPosition = -units.convertDepth(marker.depth); - } - } - - return LineChartBarData( - spots: [FlSpot(marker.timestamp.toDouble(), yPosition)], - isCurved: false, - color: Colors.transparent, - barWidth: 0, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, bar, index) { - if (marker.type == ProfileMarkerType.maxDepth) { - // Max depth: red circle with white border - return FlDotCirclePainter( - radius: size, - color: color, - strokeWidth: 2, - strokeColor: Colors.white, - ); - } else { - // Pressure threshold: colored circle with darker border - return FlDotCirclePainter( - radius: size, - color: color.withValues(alpha: 0.9), - strokeWidth: 1.5, - strokeColor: color.withValues(alpha: 0.5), - ); - } - }, - ), - ); - } - - /// Check if a specific metric has data available in this dive profile - bool _hasDataForMetric(ProfileRightAxisMetric metric) { - switch (metric) { - case ProfileRightAxisMetric.temperature: - return widget.profile.any((p) => p.temperature != null); - case ProfileRightAxisMetric.pressure: - return _hasMultiTankPressure; - case ProfileRightAxisMetric.heartRate: - return widget.profile.any((p) => p.heartRate != null); - case ProfileRightAxisMetric.sac: - return widget.sacCurve != null && widget.sacCurve!.any((s) => s > 0); - case ProfileRightAxisMetric.ndl: - return widget.ndlCurve != null && widget.ndlCurve!.isNotEmpty; - case ProfileRightAxisMetric.ppO2: - return widget.ppO2Curve != null && widget.ppO2Curve!.isNotEmpty; - case ProfileRightAxisMetric.ppN2: - return widget.ppN2Curve != null && widget.ppN2Curve!.isNotEmpty; - case ProfileRightAxisMetric.ppHe: - return widget.ppHeCurve != null && - widget.ppHeCurve!.any((v) => v > 0.001); - case ProfileRightAxisMetric.gasDensity: - return widget.densityCurve != null && widget.densityCurve!.isNotEmpty; - case ProfileRightAxisMetric.gf: - return widget.gfCurve != null && widget.gfCurve!.isNotEmpty; - case ProfileRightAxisMetric.surfaceGf: - return widget.surfaceGfCurve != null && - widget.surfaceGfCurve!.isNotEmpty; - case ProfileRightAxisMetric.meanDepth: - return widget.meanDepthCurve != null && - widget.meanDepthCurve!.isNotEmpty; - case ProfileRightAxisMetric.tts: - return widget.ttsCurve != null && widget.ttsCurve!.isNotEmpty; - case ProfileRightAxisMetric.cns: - return widget.cnsCurve != null && widget.cnsCurve!.isNotEmpty; - case ProfileRightAxisMetric.otu: - return widget.otuCurve != null && widget.otuCurve!.isNotEmpty; - } - } - - /// Get the effective right axis metric using the fallback chain - ProfileRightAxisMetric? _getEffectiveRightAxisMetric( - ProfileRightAxisMetric preferred, - ) { - // First, check if the preferred metric has data - if (_hasDataForMetric(preferred)) { - return preferred; - } - - // Fall back through the priority chain - for (final fallback in ProfileRightAxisMetric.fallbackPriority) { - if (_hasDataForMetric(fallback)) { - return fallback; - } - } - - // No metric has data - return null; - } - - /// Get the min/max value range for a metric - ({double min, double max})? _getMetricRange( - ProfileRightAxisMetric metric, - UnitFormatter units, - ) { - switch (metric) { - case ProfileRightAxisMetric.temperature: - final temps = widget.profile - .where((p) => p.temperature != null) - .map((p) => units.convertTemperature(p.temperature!)); - if (temps.isEmpty) return null; - return ( - min: temps.reduce(math.min) - 1, - max: temps.reduce(math.max) + 1, - ); - - case ProfileRightAxisMetric.pressure: - if (!_hasMultiTankPressure || widget.tankPressures == null) return null; - double? pMin, pMax; - for (final points in widget.tankPressures!.values) { - for (final pt in points) { - if (pMin == null || pt.pressure < pMin) pMin = pt.pressure; - if (pMax == null || pt.pressure > pMax) pMax = pt.pressure; - } - } - if (pMin == null || pMax == null) return null; - return (min: pMin - 10, max: pMax + 10); - - case ProfileRightAxisMetric.heartRate: - final hrs = widget.profile - .where((p) => p.heartRate != null) - .map((p) => p.heartRate!.toDouble()); - if (hrs.isEmpty) return null; - return (min: hrs.reduce(math.min) - 5, max: hrs.reduce(math.max) + 5); - - case ProfileRightAxisMetric.sac: - if (widget.sacCurve == null) return null; - final sacs = widget.sacCurve!.where((s) => s > 0); - if (sacs.isEmpty) return null; - return (min: 0.0, max: sacs.reduce(math.max) * 1.2); - - case ProfileRightAxisMetric.ndl: - return (min: 0.0, max: 3600.0); // 0-60 minutes - - case ProfileRightAxisMetric.ppO2: - return (min: 0.0, max: 2.0); // 0-2.0 bar - - case ProfileRightAxisMetric.ppN2: - return (min: 0.0, max: 5.0); // 0-5.0 bar - - case ProfileRightAxisMetric.ppHe: - return (min: 0.0, max: 3.0); // 0-3.0 bar - - case ProfileRightAxisMetric.gasDensity: - return (min: 0.0, max: 8.0); // 0-8 g/L - - case ProfileRightAxisMetric.gf: - return (min: 0.0, max: 120.0); // 0-120% - - case ProfileRightAxisMetric.surfaceGf: - return (min: 0.0, max: 150.0); // 0-150% - - case ProfileRightAxisMetric.meanDepth: - if (widget.meanDepthCurve == null) return null; - final depths = widget.meanDepthCurve!; - if (depths.isEmpty) return null; - return (min: 0.0, max: depths.reduce(math.max) * 1.1); - - case ProfileRightAxisMetric.tts: - return (min: 0.0, max: 3600.0); // 0-60 minutes - - case ProfileRightAxisMetric.cns: - if (widget.cnsCurve == null || widget.cnsCurve!.isEmpty) return null; - return (min: 0.0, max: _getCnsMaxScale()); - - case ProfileRightAxisMetric.otu: - if (widget.otuCurve == null || widget.otuCurve!.isEmpty) return null; - return (min: 0.0, max: _getOtuMaxScale()); - } - } - - /// Format right axis tick values as plain numbers (units shown in axis label). - /// - /// Values from [_getMetricRange] are in storage units (bar, meters, etc.). - /// Temperature is pre-converted in [_getMetricRange]; all others are - /// converted here at display time to match the user's unit preferences. - String _formatRightAxisValue( - ProfileRightAxisMetric metric, - double value, - UnitFormatter units, - ) { - switch (metric) { - // Temperature range is already in user units (converted in _getMetricRange) - case ProfileRightAxisMetric.temperature: - return value.toStringAsFixed(0); - // Pressure stored in bar -> convert to user unit - case ProfileRightAxisMetric.pressure: - return units.convertPressure(value).toStringAsFixed(0); - // SAC stored in bar/min -> convert pressure component to user unit - case ProfileRightAxisMetric.sac: - return units.convertPressure(value).toStringAsFixed(1); - // Mean depth stored in meters -> convert to user unit - case ProfileRightAxisMetric.meanDepth: - return units.convertDepth(value).toStringAsFixed(0); - // Universal units - no conversion needed - case ProfileRightAxisMetric.heartRate: - case ProfileRightAxisMetric.gf: - case ProfileRightAxisMetric.surfaceGf: - return value.toStringAsFixed(0); - case ProfileRightAxisMetric.ppO2: - case ProfileRightAxisMetric.ppN2: - case ProfileRightAxisMetric.ppHe: - case ProfileRightAxisMetric.gasDensity: - return value.toStringAsFixed(1); - case ProfileRightAxisMetric.ndl: - case ProfileRightAxisMetric.tts: - return (value / 60).round().toString(); - case ProfileRightAxisMetric.cns: - case ProfileRightAxisMetric.otu: - return value.toStringAsFixed(0); - } - } - - /// Build axis label text for the right axis (e.g. "Temp (°C)"). - String _rightAxisLabel(ProfileRightAxisMetric metric, UnitFormatter units) { - final name = metric.shortName; - switch (metric) { - case ProfileRightAxisMetric.temperature: - return '$name (${units.temperatureSymbol})'; - case ProfileRightAxisMetric.pressure: - return '$name (${units.pressureSymbol})'; - case ProfileRightAxisMetric.meanDepth: - return '$name (${units.depthSymbol})'; - case ProfileRightAxisMetric.sac: - return '$name (${units.pressureSymbol}/min)'; - default: - final suffix = metric.unitSuffix; - if (suffix != null) return '$name ($suffix)'; - return name; - } - } - - /// Map a depth axis value back to the metric value for axis labels - double _mapDepthToMetricValue( - double depthAxisValue, - double maxDepth, - double minValue, - double maxValue, - ) { - final normalized = 1 - (depthAxisValue / maxDepth); - return minValue + (normalized * (maxValue - minValue)); - } -} - -/// Compact version of the dive profile chart for list previews -class DiveProfileMiniChart extends StatelessWidget { - final List profile; - final double height; - final Color? color; - - const DiveProfileMiniChart({ - super.key, - required this.profile, - this.height = 40, - this.color, - }); - - @override - Widget build(BuildContext context) { - if (profile.isEmpty) { - return SizedBox(height: height); - } - - final chartColor = color ?? Theme.of(context).colorScheme.primary; - final maxDepth = profile.map((p) => p.depth).reduce(math.max) * 1.1; - final maxTime = profile.map((p) => p.timestamp).reduce(math.max).toDouble(); - - return SizedBox( - height: height, - child: LineChart( - LineChartData( - minX: 0, - maxX: maxTime, - minY: -maxDepth, // Inverted: negative depth at bottom - maxY: 0, // Surface (0m) at top - gridData: const FlGridData(show: false), - titlesData: const FlTitlesData(show: false), - borderData: FlBorderData(show: false), - lineTouchData: const LineTouchData(enabled: false), - lineBarsData: [ - LineChartBarData( - spots: profile - .map( - (p) => FlSpot(p.timestamp.toDouble(), -p.depth), - ) // Negate for inverted axis - .toList(), - // Straight segments preserve the actual sample-to-sample shape - // (safety stops, multilevel ledges, abrupt descents). Catmull- - // Rom smoothing flattens those short features into rounded - // arcs, producing a less informative "blob" silhouette. - isCurved: false, - color: chartColor, - barWidth: 1.5, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - color: chartColor.withValues(alpha: 0.2), - ), - ), - ], - ), - ), - ); - } -} +import 'dart:math' as math; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/constants/profile_metrics.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/theme/app_colors.dart'; +import 'package:submersion/core/deco/ascent_rate_calculator.dart'; +import 'package:submersion/core/utils/unit_formatter.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_markers_service.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/computer_toggle_bar.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; +import 'package:submersion/features/dive_log/domain/entities/profile_event.dart'; +import 'package:submersion/features/dive_log/presentation/providers/profile_legend_provider.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_legend.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/gas_colors.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/gas_timeline_strip.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Structured row emitted via [DiveProfileChart.onTooltipData] so callers +/// can render the tooltip externally (e.g., below the chart). +class TooltipRow { + final String label; + final String value; + final Color bulletColor; + + const TooltipRow({ + required this.label, + required this.value, + required this.bulletColor, + }); +} + +/// Interactive dive profile chart showing depth over time with zoom/pan support +class DiveProfileChart extends ConsumerStatefulWidget { + final List profile; + final Duration? diveDuration; + final double? maxDepth; + final bool showTemperature; + final bool showPressure; + final void Function(int? index)? onPointSelected; + + // Decompression visualization data (optional) + /// Ceiling curve in meters, same length as profile + final List? ceilingCurve; + + /// Ascent rate data for each profile point + final List? ascentRates; + + /// Profile events to display as markers + final List? events; + + /// NDL values in seconds for each point (-1 = in deco) + final List? ndlCurve; + + /// SAC rate curve (bar/min at surface) - smoothed for visualization + final List? sacCurve; + + /// Tank volume in liters (for L/min SAC conversion) + final double? tankVolume; + + /// Normalization factor to align profile SAC with tank-based SAC + final double sacNormalizationFactor; + + /// Whether to show ceiling by default + final bool showCeiling; + + /// Whether to color depth line by ascent rate + final bool showAscentRateColors; + + /// Whether to show event markers + final bool showEvents; + + /// Whether to show SAC curve by default + final bool showSac; + + /// Profile markers to display (max depth, pressure thresholds) + final List? markers; + + /// Whether to show max depth marker (from settings) + final bool showMaxDepthMarker; + + /// Whether to show pressure threshold markers (from settings) + final bool showPressureThresholdMarkers; + + /// Gas switches for coloring profile segments by active gas + final List? gasSwitches; + + /// Tanks for determining initial gas color (before first switch) + final List? tanks; + + /// Per-tank time-series pressure data (keyed by tank ID) + /// Used for multi-tank pressure visualization + final Map>? tankPressures; + + /// Gas-usage segments rendered as a horizontal strip directly between the + /// plot area and the X-axis tick labels. When non-empty, the chart + /// reserves [gasTimelineHeight] of extra space at the bottom and the + /// hover/playback cursor lines extend through the strip so the active + /// time can be read off both the depth profile and the gas in use. + final List? gasSegments; + + /// Total dive duration in seconds. Required when [gasSegments] is set — + /// the strip uses it to map segment timestamps to horizontal pixels. + final int? diveDurationSeconds; + + /// Height of the integrated gas timeline strip in logical pixels. + static const double gasTimelineHeight = 22.0; + + /// fl_chart default axisNameSize used for left and right axes. + static const double _leftRightAxisNameSize = 16.0; + + /// axisNameSize for the bottom (time) axis. + static const double _bottomAxisNameSize = 14.0; + + /// reservedSize for the bottom sideTitles tick-label area (no gas strip). + static const double _bottomTickReservedSize = 22.0; + + /// Optional key for exporting the chart as an image. + /// When provided, wraps the chart in a RepaintBoundary for screenshot capture. + final GlobalKey? exportKey; + + /// Optional playback cursor timestamp in seconds. + /// When provided, renders a vertical line at this position for step-through playback. + final int? playbackTimestamp; + + /// Optional highlighted timestamp in seconds (e.g. from heat map hover). + /// Renders a subtle vertical line at this position. + final int? highlightedTimestamp; + + // Advanced decompression/gas curves + /// ppO2 curve in bar + final List? ppO2Curve; + + /// ppN2 curve in bar + final List? ppN2Curve; + + /// ppHe curve in bar (for trimix) + final List? ppHeCurve; + + /// MOD curve in meters + final List? modCurve; + + /// Gas density curve in g/L + final List? densityCurve; + + /// Gradient Factor % curve (0-100+) + final List? gfCurve; + + /// Surface GF% curve (0-100+) + final List? surfaceGfCurve; + + /// Mean depth curve in meters + final List? meanDepthCurve; + + /// TTS (Time To Surface) curve in seconds + final List? ttsCurve; + + /// Cumulative CNS% curve (includes residual from prior dives) + final List? cnsCurve; + + /// Cumulative OTU curve + final List? otuCurve; + + // Multi-computer rendering parameters + /// Map of computerId -> profile points for multi-computer rendering. + /// When non-null with 2+ entries, each computer is drawn with its own color. + final Map>? computerProfiles; + + /// Set of currently visible computer IDs. + /// When null, all computers in [computerProfiles] are visible. + final Set? visibleComputers; + + /// Map of computerId -> color for multi-computer rendering. + final Map? computerLineColors; + + /// Set of computer IDs that use a solid line (primaries). + /// Computers not in this set use a dashed line style. + final Set? primaryComputers; + + /// When true, the built-in tooltip is suppressed and tooltip data is + /// emitted via [onTooltipData] so callers can render it externally + /// (e.g., below the chart in the profile panel). + final bool tooltipBelow; + + /// Called with structured tooltip row data when a point is touched + /// and [tooltipBelow] is true. Null clears the tooltip. + final void Function(List? rows)? onTooltipData; + + /// Returns responsive left axis reserved size based on available chart width. + /// Tick labels are plain numbers (e.g. "30", "60") so don't need much space. + static double leftAxisSize(double availableWidth) => + availableWidth < 350 ? 28.0 : 32.0; + + /// Returns responsive right axis reserved size based on available chart width. + /// Needs extra room for 4-digit values like PSI pressure (e.g. "3000"). + static double rightAxisSize(double availableWidth) => + availableWidth < 350 ? 32.0 : 38.0; + + const DiveProfileChart({ + super.key, + required this.profile, + this.diveDuration, + this.maxDepth, + this.showTemperature = true, + this.showPressure = false, + this.onPointSelected, + this.ceilingCurve, + this.ascentRates, + this.events, + this.ndlCurve, + this.sacCurve, + this.tankVolume, + this.sacNormalizationFactor = 1.0, + this.showCeiling = true, + this.showAscentRateColors = true, + this.showEvents = true, + this.showSac = false, + this.markers, + this.showMaxDepthMarker = false, + this.showPressureThresholdMarkers = false, + this.gasSwitches, + this.tanks, + this.tankPressures, + this.gasSegments, + this.diveDurationSeconds, + this.exportKey, + this.playbackTimestamp, + this.highlightedTimestamp, + this.ppO2Curve, + this.ppN2Curve, + this.ppHeCurve, + this.modCurve, + this.densityCurve, + this.gfCurve, + this.surfaceGfCurve, + this.meanDepthCurve, + this.ttsCurve, + this.cnsCurve, + this.otuCurve, + this.computerProfiles, + this.visibleComputers, + this.computerLineColors, + this.primaryComputers, + this.tooltipBelow = false, + this.onTooltipData, + }); + + @override + ConsumerState createState() => _DiveProfileChartState(); +} + +class _DiveProfileChartState extends ConsumerState { + bool _showTemperature = true; + + bool _showHeartRate = false; + bool _showSac = false; + + // Per-tank pressure visibility (keyed by tank ID) + // Defaults to all visible; populated on first build if multi-tank data exists + final Map _showTankPressure = {}; + + // Decompression visualization toggles + bool _showCeiling = true; + bool _showAscentRateColors = true; + bool _showEvents = true; + + // Profile marker toggles + bool _showMaxDepthMarkerLocal = true; + bool _showPressureMarkersLocal = true; + + // Gas switch visualization toggle + bool _showGasSwitchMarkers = true; + + // Advanced decompression/gas toggles + bool _showNdl = false; + bool _showPpO2 = false; + bool _showPpN2 = false; + bool _showPpHe = false; + bool _showMod = false; + bool _showDensity = false; + bool _showGf = false; + bool _showSurfaceGf = false; + bool _showMeanDepth = false; + bool _showTts = false; + bool _showCns = false; + bool _showOtu = false; + + // Helper getters for marker availability + bool get _hasMaxDepthMarker => + widget.markers?.any((m) => m.type == ProfileMarkerType.maxDepth) ?? false; + + bool get _hasPressureMarkers => + widget.markers?.any((m) => m.type != ProfileMarkerType.maxDepth) ?? false; + + /// Whether multi-tank pressure data is available + bool get _hasMultiTankPressure => + widget.tankPressures != null && widget.tankPressures!.isNotEmpty; + + /// Get tank by ID for display purposes + DiveTank? _getTankById(String tankId) { + final tanks = widget.tanks; + if (tanks == null) return null; + for (final tank in tanks) { + if (tank.id == tankId) return tank; + } + return null; + } + + /// Sort tank IDs by tank order + List _sortedTankIds(Iterable tankIds) { + final ids = tankIds.toList(); + ids.sort((a, b) { + final orderA = _getTankById(a)?.order ?? 999; + final orderB = _getTankById(b)?.order ?? 999; + return orderA.compareTo(orderB); + }); + return ids; + } + + /// Get color for ascent rate category + Color _getAscentRateColor(AscentRateCategory category) { + switch (category) { + case AscentRateCategory.safe: + return Colors.green; + case AscentRateCategory.warning: + return Colors.orange; + case AscentRateCategory.danger: + return Colors.red; + } + } + + /// Interpolate tank pressure at a given timestamp + double? _interpolateTankPressure( + List points, + int timestamp, + ) { + if (points.isEmpty) return null; + + // Find surrounding points + TankPressurePoint? before; + TankPressurePoint? after; + + for (final point in points) { + if (point.timestamp <= timestamp) { + before = point; + } else { + after = point; + break; + } + } + + // Exact match or only before point + if (before != null && (after == null || before.timestamp == timestamp)) { + return before.pressure; + } + + // Only after point (timestamp before first data point) + if (before == null && after != null) { + return after.pressure; + } + + // Interpolate between before and after + if (before != null && after != null) { + final t = + (timestamp - before.timestamp) / (after.timestamp - before.timestamp); + return before.pressure + (after.pressure - before.pressure) * t; + } + + return null; + } + + /// Get color for tank by index (fallback when no gas mix info) + Color _getTankColor(int index) { + const colors = [ + Colors.orange, + Colors.amber, + Colors.green, + Colors.cyan, + Colors.purple, + Colors.pink, + ]; + return colors[index % colors.length]; + } + + /// Get dash pattern for tank by index + List? _getTankDashPattern(int index) { + switch (index) { + case 0: + return [8, 4]; // Primary: long dash + case 1: + return [4, 4]; // Secondary: medium dash + case 2: + return [2, 2]; // Tertiary: short dash + case 3: + return [8, 2, 2, 2]; // Fourth: dash-dot + default: + return [4, 2]; + } + } + + // Zoom/pan state + double _zoomLevel = 1.0; + double _panOffsetX = 0.0; // Normalized offset (0-1 range based on total data) + double _panOffsetY = 0.0; + + // For gesture handling + double _previousZoom = 1.0; + Offset _previousPan = Offset.zero; + Offset _startFocalPoint = Offset.zero; + + // Zoom limits + static const double _minZoom = 1.0; + static const double _maxZoom = 10.0; + + // Tooltip memoization + int? _lastTooltipSpotIndex; + List _lastTooltipItems = []; + + @override + void initState() { + super.initState(); + _showTemperature = widget.showTemperature; + _showSac = widget.showSac; + _showCeiling = widget.showCeiling; + _showAscentRateColors = widget.showAscentRateColors; + _showEvents = widget.showEvents; + _scheduleTankPressureVisibilityInitialization(); + } + + @override + void didUpdateWidget(covariant DiveProfileChart oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.profile != widget.profile) { + _lastTooltipSpotIndex = null; + _lastTooltipItems = []; + } + if (oldWidget.tankPressures != widget.tankPressures) { + _scheduleTankPressureVisibilityInitialization(); + } + } + + void _scheduleTankPressureVisibilityInitialization() { + if (!_hasMultiTankPressure) return; + final tankIds = widget.tankPressures!.keys.toList(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || tankIds.isEmpty) return; + ref.read(profileLegendProvider.notifier).initializeTankPressures(tankIds); + }); + } + + void _resetZoom() { + setState(() { + _zoomLevel = 1.0; + _panOffsetX = 0.0; + _panOffsetY = 0.0; + }); + } + + /// Build and emit [TooltipRow] data for external rendering when + /// [DiveProfileChart.tooltipBelow] is true. + void _emitExternalTooltip( + List touchedSpots, + UnitFormatter units, + ColorScheme colorScheme, + ) { + if (widget.onTooltipData == null) return; + + final spot = touchedSpots.where((s) => s.barIndex == 0).firstOrNull; + if (spot == null || spot.spotIndex >= widget.profile.length) { + widget.onTooltipData!(null); + return; + } + + final point = widget.profile[spot.spotIndex]; + final rows = []; + final onSurface = colorScheme.onInverseSurface; + + // Time + final minutes = point.timestamp ~/ 60; + final seconds = point.timestamp % 60; + rows.add( + TooltipRow( + label: 'Time', + value: '$minutes:${seconds.toString().padLeft(2, '0')}', + bulletColor: onSurface.withValues(alpha: 0.5), + ), + ); + + // Depth + rows.add( + TooltipRow( + label: 'Depth', + value: units.formatDepth(point.depth), + bulletColor: AppColors.chartDepth, + ), + ); + + // Temperature + if (_showTemperature) { + rows.add( + TooltipRow( + label: 'Temp', + value: point.temperature != null + ? units.formatTemperature(point.temperature) + : '-', + bulletColor: colorScheme.tertiary, + ), + ); + } + + // Ceiling + if (_showCeiling && + widget.ceilingCurve != null && + spot.spotIndex < widget.ceilingCurve!.length) { + final ceiling = widget.ceilingCurve![spot.spotIndex]; + rows.add( + TooltipRow( + label: 'Ceiling', + value: ceiling > 0 ? units.formatDepth(ceiling) : '-', + bulletColor: const Color(0xFFD32F2F), + ), + ); + } + + // Ascent rate + if (_showAscentRateColors && + widget.ascentRates != null && + spot.spotIndex < widget.ascentRates!.length) { + final ascentRate = widget.ascentRates![spot.spotIndex]; + final rate = ascentRate.rateMetersPerMin; + final convertedRate = units.convertDepth(rate.abs()); + String arrow = '-'; + Color rateColor = Colors.grey; + if (rate > 0.5) { + arrow = '\u2191'; + rateColor = ascentRate.category == AscentRateCategory.safe + ? Colors.lime + : _getAscentRateColor(ascentRate.category); + } else if (rate < -0.5) { + arrow = '\u2193'; + rateColor = Colors.cyan; + } + rows.add( + TooltipRow( + label: 'Rate', + value: + '$arrow ${convertedRate.toStringAsFixed(1)} ${units.depthSymbol}/min', + bulletColor: rateColor, + ), + ); + } + + // Heart rate + if (_showHeartRate) { + rows.add( + TooltipRow( + label: 'HR', + value: point.heartRate != null ? '${point.heartRate} bpm' : '-', + bulletColor: Colors.red, + ), + ); + } + + // SAC + if (_showSac && + widget.sacCurve != null && + spot.spotIndex < widget.sacCurve!.length) { + final sacBarPerMin = widget.sacCurve![spot.spotIndex]; + String sacValue = '-'; + if (sacBarPerMin > 0) { + final normalizedSac = sacBarPerMin * widget.sacNormalizationFactor; + final sacUnit = ref.read(settingsProvider).sacUnit; + if (sacUnit == SacUnit.litersPerMin && widget.tankVolume != null) { + final sacLPerMin = normalizedSac * widget.tankVolume!; + sacValue = + '${units.convertVolume(sacLPerMin).toStringAsFixed(1)} ${units.volumeSymbol}/min'; + } else { + sacValue = + '${units.convertPressure(normalizedSac).toStringAsFixed(1)} ${units.pressureSymbol}/min'; + } + } + rows.add( + TooltipRow(label: 'SAC', value: sacValue, bulletColor: Colors.teal), + ); + } + + // NDL + if (_showNdl && + widget.ndlCurve != null && + spot.spotIndex < widget.ndlCurve!.length) { + final ndl = widget.ndlCurve![spot.spotIndex]; + String ndlValue; + if (ndl < 0) { + ndlValue = 'DECO'; + } else if (ndl < 3600) { + final min = ndl ~/ 60; + final sec = ndl % 60; + ndlValue = '$min:${sec.toString().padLeft(2, '0')}'; + } else { + ndlValue = '>60 min'; + } + rows.add( + TooltipRow( + label: 'NDL', + value: ndlValue, + bulletColor: Colors.yellow.shade700, + ), + ); + } + + // ppO2 + if (_showPpO2 && + widget.ppO2Curve != null && + spot.spotIndex < widget.ppO2Curve!.length) { + rows.add( + TooltipRow( + label: 'ppO2', + value: '${widget.ppO2Curve![spot.spotIndex].toStringAsFixed(2)} bar', + bulletColor: const Color(0xFF00ACC1), + ), + ); + } + + // ppN2 + if (_showPpN2 && + widget.ppN2Curve != null && + spot.spotIndex < widget.ppN2Curve!.length) { + rows.add( + TooltipRow( + label: 'ppN2', + value: '${widget.ppN2Curve![spot.spotIndex].toStringAsFixed(2)} bar', + bulletColor: Colors.indigo, + ), + ); + } + + // ppHe + if (_showPpHe && + widget.ppHeCurve != null && + spot.spotIndex < widget.ppHeCurve!.length) { + final ppHe = widget.ppHeCurve![spot.spotIndex]; + if (ppHe > 0.001) { + rows.add( + TooltipRow( + label: 'ppHe', + value: '${ppHe.toStringAsFixed(2)} bar', + bulletColor: Colors.pink.shade300, + ), + ); + } + } + + // MOD + if (_showMod && + widget.modCurve != null && + spot.spotIndex < widget.modCurve!.length) { + final mod = widget.modCurve![spot.spotIndex]; + if (mod > 0 && mod < 200) { + rows.add( + TooltipRow( + label: 'MOD', + value: units.formatDepth(mod), + bulletColor: Colors.deepOrange, + ), + ); + } + } + + // Gas density + if (_showDensity && + widget.densityCurve != null && + spot.spotIndex < widget.densityCurve!.length) { + rows.add( + TooltipRow( + label: 'Density', + value: + '${widget.densityCurve![spot.spotIndex].toStringAsFixed(2)} g/L', + bulletColor: Colors.brown, + ), + ); + } + + // GF% + if (_showGf && + widget.gfCurve != null && + spot.spotIndex < widget.gfCurve!.length) { + rows.add( + TooltipRow( + label: 'GF', + value: '${widget.gfCurve![spot.spotIndex].toStringAsFixed(0)}%', + bulletColor: Colors.deepPurple, + ), + ); + } + + // Surface GF + if (_showSurfaceGf && + widget.surfaceGfCurve != null && + spot.spotIndex < widget.surfaceGfCurve!.length) { + rows.add( + TooltipRow( + label: 'Srf GF', + value: + '${widget.surfaceGfCurve![spot.spotIndex].toStringAsFixed(0)}%', + bulletColor: Colors.purple.shade300, + ), + ); + } + + // Mean depth + if (_showMeanDepth && + widget.meanDepthCurve != null && + spot.spotIndex < widget.meanDepthCurve!.length) { + rows.add( + TooltipRow( + label: 'Mean', + value: units.formatDepth(widget.meanDepthCurve![spot.spotIndex]), + bulletColor: Colors.blueGrey, + ), + ); + } + + // TTS + if (_showTts && + widget.ttsCurve != null && + spot.spotIndex < widget.ttsCurve!.length) { + final tts = widget.ttsCurve![spot.spotIndex]; + rows.add( + TooltipRow( + label: 'TTS', + value: tts > 0 ? '${(tts / 60).ceil()} min' : '0 min', + bulletColor: const Color(0xFFAD1457), + ), + ); + } + + // CNS% + if (_showCns && + widget.cnsCurve != null && + spot.spotIndex < widget.cnsCurve!.length) { + rows.add( + TooltipRow( + label: 'CNS', + value: '${widget.cnsCurve![spot.spotIndex].toStringAsFixed(1)}%', + bulletColor: const Color(0xFFE65100), + ), + ); + } + + // OTU + if (_showOtu && + widget.otuCurve != null && + spot.spotIndex < widget.otuCurve!.length) { + rows.add( + TooltipRow( + label: 'OTU', + value: widget.otuCurve![spot.spotIndex].toStringAsFixed(0), + bulletColor: const Color(0xFF6D4C41), + ), + ); + } + + // Per-tank pressure + if (widget.tankPressures != null) { + final timestamp = point.timestamp; + final sortedTankIds = _sortedTankIds(widget.tankPressures!.keys); + for (var i = 0; i < sortedTankIds.length; i++) { + final tankId = sortedTankIds[i]; + if (!(_showTankPressure[tankId] ?? true)) continue; + final pressurePoints = widget.tankPressures![tankId]; + if (pressurePoints == null || pressurePoints.isEmpty) continue; + final pressure = _interpolateTankPressure(pressurePoints, timestamp); + final tank = _getTankById(tankId); + final color = tank != null + ? GasColors.forGasMix(tank.gasMix) + : _getTankColor(i); + final tankLabel = tank?.name ?? 'Tank ${i + 1}'; + rows.add( + TooltipRow( + label: tankLabel, + value: pressure != null ? units.formatPressure(pressure) : '-', + bulletColor: color, + ), + ); + } + } + + // Marker info (if touching near a marker) + if (widget.markers != null && widget.markers!.isNotEmpty) { + final timestamp = point.timestamp; + const timestampThreshold = 3; + for (final marker in widget.markers!) { + if (marker.type == ProfileMarkerType.maxDepth) { + if (!widget.showMaxDepthMarker || !_showMaxDepthMarkerLocal) continue; + } else { + if (!widget.showPressureThresholdMarkers || + !_showPressureMarkersLocal) { + continue; + } + } + if ((marker.timestamp - timestamp).abs() <= timestampThreshold) { + rows.add( + TooltipRow( + label: 'Marker', + value: marker.chartLabel, + bulletColor: marker.getColor(), + ), + ); + } + } + } + + widget.onTooltipData!(rows); + } + + void _zoomIn() { + setState(() { + _zoomLevel = (_zoomLevel * 1.5).clamp(_minZoom, _maxZoom); + _clampPanOffsets(); + }); + } + + void _zoomOut() { + setState(() { + _zoomLevel = (_zoomLevel / 1.5).clamp(_minZoom, _maxZoom); + _clampPanOffsets(); + }); + } + + void _clampPanOffsets() { + // Calculate maximum allowed pan based on zoom level + final maxPan = 1.0 - (1.0 / _zoomLevel); + _panOffsetX = _panOffsetX.clamp(0.0, maxPan); + _panOffsetY = _panOffsetY.clamp(0.0, maxPan); + } + + @override + Widget build(BuildContext context) { + if (widget.profile.isEmpty) { + return _buildEmptyState(context); + } + + final settings = ref.watch(settingsProvider); + final units = UnitFormatter(settings); + final hasTemperatureData = widget.profile.any((p) => p.temperature != null); + final hasPressureData = _hasMultiTankPressure; + final hasHeartRateData = widget.profile.any((p) => p.heartRate != null); + final colorScheme = Theme.of(context).colorScheme; + + // Watch legend state from provider + final legendState = ref.watch(profileLegendProvider); + + // Sync local state with provider for backward compatibility + // This allows the chart rendering logic to continue using local state + _showTemperature = legendState.showTemperature; + _showHeartRate = legendState.showHeartRate; + _showSac = legendState.showSac; + _showCeiling = legendState.showCeiling; + _showAscentRateColors = legendState.showAscentRateColors; + _showEvents = legendState.showEvents; + _showMaxDepthMarkerLocal = legendState.showMaxDepthMarker; + _showPressureMarkersLocal = legendState.showPressureMarkers; + _showGasSwitchMarkers = legendState.showGasSwitchMarkers; + // Sync advanced deco/gas toggles + _showNdl = legendState.showNdl; + _showPpO2 = legendState.showPpO2; + _showPpN2 = legendState.showPpN2; + _showPpHe = legendState.showPpHe; + _showMod = legendState.showMod; + _showDensity = legendState.showDensity; + _showGf = legendState.showGf; + _showSurfaceGf = legendState.showSurfaceGf; + _showMeanDepth = legendState.showMeanDepth; + _showTts = legendState.showTts; + _showCns = legendState.showCns; + _showOtu = legendState.showOtu; + // Sync per-tank pressure visibility + for (final entry in legendState.showTankPressure.entries) { + _showTankPressure[entry.key] = entry.value; + } + + // Check data availability for advanced curves + final hasNdlData = widget.ndlCurve != null && widget.ndlCurve!.isNotEmpty; + final hasPpO2Data = + widget.ppO2Curve != null && widget.ppO2Curve!.isNotEmpty; + final hasPpN2Data = + widget.ppN2Curve != null && widget.ppN2Curve!.isNotEmpty; + final hasPpHeData = + widget.ppHeCurve != null && widget.ppHeCurve!.any((v) => v > 0.001); + final hasModData = widget.modCurve != null && widget.modCurve!.isNotEmpty; + final hasDensityData = + widget.densityCurve != null && widget.densityCurve!.isNotEmpty; + final hasGfData = widget.gfCurve != null && widget.gfCurve!.isNotEmpty; + final hasSurfaceGfData = + widget.surfaceGfCurve != null && widget.surfaceGfCurve!.isNotEmpty; + final hasMeanDepthData = + widget.meanDepthCurve != null && widget.meanDepthCurve!.isNotEmpty; + final hasTtsData = widget.ttsCurve != null && widget.ttsCurve!.isNotEmpty; + final hasCnsData = widget.cnsCurve != null && widget.cnsCurve!.isNotEmpty; + final hasOtuData = widget.otuCurve != null && widget.otuCurve!.isNotEmpty; + + // Build legend config based on available data + final legendConfig = ProfileLegendConfig( + hasTemperatureData: hasTemperatureData, + hasPressureData: hasPressureData, + hasHeartRateData: hasHeartRateData, + hasSacCurve: widget.sacCurve != null && widget.sacCurve!.isNotEmpty, + hasCeilingCurve: widget.ceilingCurve != null, + hasAscentRates: widget.ascentRates != null, + hasEvents: widget.events != null && widget.events!.isNotEmpty, + hasMaxDepthMarker: widget.showMaxDepthMarker && _hasMaxDepthMarker, + hasPressureMarkers: + widget.showPressureThresholdMarkers && _hasPressureMarkers, + hasGasSwitches: + widget.gasSwitches != null && widget.gasSwitches!.isNotEmpty, + hasMultiTankPressure: _hasMultiTankPressure, + hasGasData: + (widget.gasSegments?.isNotEmpty ?? false) && + (widget.diveDurationSeconds != null && + widget.diveDurationSeconds! > 0), + tanks: widget.tanks, + tankPressures: widget.tankPressures, + hasNdlData: hasNdlData, + hasPpO2Data: hasPpO2Data, + hasPpN2Data: hasPpN2Data, + hasPpHeData: hasPpHeData, + hasModData: hasModData, + hasDensityData: hasDensityData, + hasGfData: hasGfData, + hasSurfaceGfData: hasSurfaceGfData, + hasMeanDepthData: hasMeanDepthData, + hasTtsData: hasTtsData, + hasCnsData: hasCnsData, + hasOtuData: hasOtuData, + ); + + return LayoutBuilder( + builder: (context, constraints) { + // Left axis offset = axisNameSize + sideTitles reservedSize + final legendLeftPadding = + DiveProfileChart._leftRightAxisNameSize + + DiveProfileChart.leftAxisSize(constraints.maxWidth); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Chart header with legend and zoom controls (decluttered) + DiveProfileLegend( + config: legendConfig, + zoomLevel: _zoomLevel, + minZoom: _minZoom, + maxZoom: _maxZoom, + onZoomIn: _zoomIn, + onZoomOut: _zoomOut, + onResetZoom: _resetZoom, + leftPadding: legendLeftPadding, + ), + + // The chart with gesture handling + // Wrapped in RepaintBoundary for PNG export when exportKey is provided + RepaintBoundary( + key: widget.exportKey, + child: SizedBox( + height: 200, + child: _buildInteractiveChart( + context, + units, + hasTemperatureData: hasTemperatureData, + hasPressureData: hasPressureData, + hasHeartRateData: hasHeartRateData, + ), + ), + ), + // Zoom hint + if (_zoomLevel > 1.0) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + context.l10n.diveLog_profile_zoomHint( + _zoomLevel.toStringAsFixed(1), + ), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildInteractiveChart( + BuildContext context, + UnitFormatter units, { + required bool hasTemperatureData, + required bool hasPressureData, + required bool hasHeartRateData, + }) { + return LayoutBuilder( + builder: (context, constraints) { + return Semantics( + label: context.l10n.diveLog_profile_semantics_chart, + child: GestureDetector( + onScaleStart: (details) { + _previousZoom = _zoomLevel; + _previousPan = Offset(_panOffsetX, _panOffsetY); + _startFocalPoint = details.localFocalPoint; + }, + onScaleUpdate: (details) { + // Only apply zoom/pan for multi-touch (pinch) gestures. + // Single-finger drag is handled by fl_chart's touchCallback + // without gesture arena disambiguation delay. + if (details.pointerCount < 2) return; + + setState(() { + // Handle zoom + final newZoom = (_previousZoom * details.scale).clamp( + _minZoom, + _maxZoom, + ); + + // Handle pan + final panDelta = details.localFocalPoint - _startFocalPoint; + + // Convert pixel delta to normalized offset based on chart size + final chartWidth = constraints.maxWidth; + final chartHeight = constraints.maxHeight; + + // Only apply pan if zoomed in + if (newZoom > 1.0) { + final normalizedDeltaX = -panDelta.dx / chartWidth / newZoom; + final normalizedDeltaY = -panDelta.dy / chartHeight / newZoom; + + _panOffsetX = (_previousPan.dx + normalizedDeltaX).clamp( + 0.0, + 1.0 - (1.0 / newZoom), + ); + _panOffsetY = (_previousPan.dy + normalizedDeltaY).clamp( + 0.0, + 1.0 - (1.0 / newZoom), + ); + } else { + _panOffsetX = 0.0; + _panOffsetY = 0.0; + } + + _zoomLevel = newZoom; + }); + }, + onDoubleTap: () { + if (_zoomLevel > 1.0) { + _resetZoom(); + } else { + setState(() { + _zoomLevel = 2.0; + }); + } + }, + child: Listener( + onPointerSignal: (event) { + // Handle mouse scroll wheel for zoom + if (event is PointerScrollEvent) { + setState(() { + final scrollDelta = event.scrollDelta.dy; + if (scrollDelta < 0) { + // Scroll up = zoom in + _zoomLevel = (_zoomLevel * 1.1).clamp(_minZoom, _maxZoom); + } else { + // Scroll down = zoom out + _zoomLevel = (_zoomLevel / 1.1).clamp(_minZoom, _maxZoom); + } + _clampPanOffsets(); + }); + } + }, + child: _buildChart( + context, + units, + availableWidth: constraints.maxWidth, + hasTemperatureData: hasTemperatureData, + hasPressureData: hasPressureData, + hasHeartRateData: hasHeartRateData, + ), + ), + ), + ); + }, + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Container( + height: 200, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ExcludeSemantics( + child: Icon( + Icons.show_chart, + size: 48, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 8), + Text( + context.l10n.diveLog_profile_emptyState, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + /// Whether the integrated gas timeline strip should be rendered for the + /// current dive. True iff segments and a positive dive duration were + /// supplied AND the user has not hidden the strip via the chart options + /// menu — keeps the chart self-contained and lets us cheaply branch in + /// the layout code without nullable bookkeeping at every call site. + bool get _hasGasStrip => + (widget.gasSegments?.isNotEmpty ?? false) && + (widget.diveDurationSeconds != null && widget.diveDurationSeconds! > 0) && + ref.watch(profileLegendProvider.select((s) => s.showGas)); + + Widget _buildChart( + BuildContext context, + UnitFormatter units, { + required double availableWidth, + required bool hasTemperatureData, + required bool hasPressureData, + required bool hasHeartRateData, + }) { + final colorScheme = Theme.of(context).colorScheme; + final sacUnit = ref.read(sacUnitProvider); + const heartRateColor = Colors.red; + + // Calculate full data bounds (all values stored in meters, convert for display) + final totalMaxTime = widget.profile + .map((p) => p.timestamp) + .reduce(math.max) + .toDouble(); + final maxDepthValueMeters = widget.profile + .map((p) => p.depth) + .reduce(math.max); + // Convert to user's preferred depth unit for chart calculations + final maxDepthValueDisplay = units.convertDepth( + widget.maxDepth ?? maxDepthValueMeters, + ); + final totalMaxDepth = maxDepthValueDisplay * 1.1; // Add 10% padding + + // Apply zoom and pan to calculate visible bounds + final visibleRangeX = totalMaxTime / _zoomLevel; + final visibleRangeY = totalMaxDepth / _zoomLevel; + + final visibleMinX = _panOffsetX * totalMaxTime; + final visibleMaxX = visibleMinX + visibleRangeX; + + final visibleMinDepth = _panOffsetY * totalMaxDepth; + final visibleMaxDepth = visibleMinDepth + visibleRangeY; + + // Temperature bounds (if showing) - convert to user's preferred unit + double? minTemp, maxTemp; + if (_showTemperature && hasTemperatureData) { + final temps = widget.profile + .where((p) => p.temperature != null) + .map((p) => units.convertTemperature(p.temperature!)); + if (temps.isNotEmpty) { + minTemp = temps.reduce(math.min) - 1; + maxTemp = temps.reduce(math.max) + 1; + } + } + + // Determine effective right axis metric using settings default and fallback chain. + // getEffectiveRightAxisMetric() returns null when the user chose "None". + final legendNotifier = ref.read(profileLegendProvider.notifier); + final preferredMetric = legendNotifier.getEffectiveRightAxisMetric(); + final effectiveRightAxisMetric = preferredMetric != null + ? _getEffectiveRightAxisMetric(preferredMetric) + : null; + final rightAxisRange = effectiveRightAxisMetric != null + ? _getMetricRange(effectiveRightAxisMetric, units) + : null; + + // Pressure bounds from multi-tank pressure data + double? minPressure, maxPressure; + if (_hasMultiTankPressure && widget.tankPressures != null) { + for (final pressurePoints in widget.tankPressures!.values) { + for (final point in pressurePoints) { + if (minPressure == null || point.pressure < minPressure) { + minPressure = point.pressure - 10; + } + if (maxPressure == null || point.pressure > maxPressure) { + maxPressure = point.pressure + 10; + } + } + } + } + + // Heart rate bounds (if showing) + double? minHR, maxHR; + if (_showHeartRate && hasHeartRateData) { + final hrs = widget.profile + .where((p) => p.heartRate != null) + .map((p) => p.heartRate!.toDouble()); + if (hrs.isNotEmpty) { + minHR = hrs.reduce(math.min) - 5; + maxHR = hrs.reduce(math.max) + 5; + } + } + + // SAC bounds (if showing) + double? minSac, maxSac; + final hasSacData = widget.sacCurve != null && widget.sacCurve!.isNotEmpty; + if (_showSac && hasSacData) { + final sacs = widget.sacCurve!.where((s) => s > 0); + if (sacs.isNotEmpty) { + minSac = 0; // Always start from 0 for SAC + maxSac = sacs.reduce(math.max) * 1.2; // Add 20% headroom + } + } + + return Stack( + children: [ + LineChart( + LineChartData( + minX: visibleMinX, + maxX: visibleMaxX, + minY: -visibleMaxDepth, // Inverted: negative depth at bottom + maxY: -visibleMinDepth, // Surface area at top (inverted) + clipData: + const FlClipData.all(), // Clip data points outside visible area + gridData: FlGridData( + show: true, + drawVerticalLine: true, + horizontalInterval: _calculateDepthInterval(visibleRangeY), + verticalInterval: _calculateTimeInterval(visibleRangeX), + getDrawingHorizontalLine: (value) => FlLine( + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + strokeWidth: 1, + ), + getDrawingVerticalLine: (value) => FlLine( + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + axisNameWidget: Text( + context.l10n.diveLog_profile_axisDepth(units.depthSymbol), + style: Theme.of(context).textTheme.labelSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + sideTitles: SideTitles( + showTitles: true, + reservedSize: DiveProfileChart.leftAxisSize(availableWidth), + interval: _calculateDepthInterval(visibleRangeY), + getTitlesWidget: (value, meta) { + // Suppress interval ticks too close to the min boundary + // (min is the most-negative value = deepest depth). + final interval = _calculateDepthInterval(visibleRangeY); + final distToMin = (value - meta.min).abs(); + if (distToMin > 0 && distToMin < interval * 0.4) { + return const SizedBox.shrink(); + } + // Show positive depth values (negate the negative axis values) + return SideTitleWidget( + meta: meta, + child: Text( + '${(-value).toInt()}', + style: Theme.of(context).textTheme.labelSmall, + maxLines: 1, + ), + ); + }, + ), + ), + bottomTitles: AxisTitles( + axisNameWidget: Text( + context.l10n.diveLog_profile_axisTime, + style: Theme.of(context).textTheme.labelSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + axisNameSize: DiveProfileChart._bottomAxisNameSize, + sideTitles: SideTitles( + showTitles: true, + // When the gas strip is rendered, reserve extra room and + // push the tick labels down by the strip's height so the + // strip can be Positioned in the resulting gap, directly + // between the plot area and the time labels. + reservedSize: _hasGasStrip + ? DiveProfileChart._bottomTickReservedSize + + DiveProfileChart.gasTimelineHeight + : DiveProfileChart._bottomTickReservedSize, + interval: _calculateTimeInterval(visibleRangeX), + getTitlesWidget: (value, meta) { + // Suppress interval ticks that are too close to the max + // boundary to prevent overlapping labels. + final interval = _calculateTimeInterval(visibleRangeX); + final distToMax = (meta.max - value).abs(); + if (distToMax > 0 && distToMax < interval * 0.4) { + return const SizedBox.shrink(); + } + final minutes = (value / 60).round(); + return SideTitleWidget( + meta: meta, + space: _hasGasStrip + ? 8 + DiveProfileChart.gasTimelineHeight + : 8, + child: Text( + '$minutes', + style: Theme.of(context).textTheme.labelSmall, + maxLines: 1, + ), + ); + }, + ), + ), + rightTitles: AxisTitles( + axisNameWidget: + effectiveRightAxisMetric != null && rightAxisRange != null + ? Text( + _rightAxisLabel(effectiveRightAxisMetric, units), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: effectiveRightAxisMetric.getColor(colorScheme), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + sideTitles: SideTitles( + showTitles: + effectiveRightAxisMetric != null && + rightAxisRange != null, + reservedSize: DiveProfileChart.rightAxisSize(availableWidth), + getTitlesWidget: (value, meta) { + if (effectiveRightAxisMetric == null || + rightAxisRange == null) { + return const SizedBox(); + } + // Suppress interval ticks too close to the min boundary + final interval = _calculateDepthInterval(visibleRangeY); + final distToMin = (value - meta.min).abs(); + if (distToMin > 0 && distToMin < interval * 0.4) { + return const SizedBox.shrink(); + } + // Map from inverted depth axis to the metric value + final metricValue = _mapDepthToMetricValue( + -value, + totalMaxDepth, + rightAxisRange.min, + rightAxisRange.max, + ); + if (metricValue < rightAxisRange.min || + metricValue > rightAxisRange.max) { + return const SizedBox(); + } + final metricColor = effectiveRightAxisMetric.getColor( + colorScheme, + ); + return SideTitleWidget( + meta: meta, + child: Text( + _formatRightAxisValue( + effectiveRightAxisMetric, + metricValue, + units, + ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metricColor), + maxLines: 1, + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData( + show: true, + border: Border.all(color: colorScheme.outlineVariant), + ), + lineBarsData: [ + // Depth line segments (colored by active gas if gas switches exist) + ..._buildGasColoredDepthLines(colorScheme, units), + + // Gas switch markers (if showing and data available) + if (_showGasSwitchMarkers) ..._buildGasSwitchMarkers(units), + + // Temperature line (if showing) + if (_showTemperature && + hasTemperatureData && + minTemp != null && + maxTemp != null) + _buildTemperatureLine( + colorScheme, + totalMaxDepth, + minTemp, + maxTemp, + units, + ), + + // Multi-tank pressure lines (per-tank visibility controlled + // inside _buildMultiTankPressureLines via _showTankPressure) + if (_hasMultiTankPressure) + ..._buildMultiTankPressureLines(totalMaxDepth), + + // Heart rate line (if showing) + if (_showHeartRate && + hasHeartRateData && + minHR != null && + maxHR != null) + _buildHeartRateLine( + heartRateColor, + totalMaxDepth, + minHR, + maxHR, + ), + + // SAC curve line (if showing) + if (_showSac && hasSacData && minSac != null && maxSac != null) + _buildSacLine(totalMaxDepth, minSac, maxSac), + + // Ceiling line (if showing and data available) + if (_showCeiling && widget.ceilingCurve != null) + _buildCeilingLine(units), + + // NDL line (if showing) + if (_showNdl && widget.ndlCurve != null) + _buildNdlLine(totalMaxDepth), + + // ppO2 line (if showing) + if (_showPpO2 && widget.ppO2Curve != null) + _buildPpO2Line(totalMaxDepth), + + // ppN2 line (if showing) + if (_showPpN2 && widget.ppN2Curve != null) + _buildPpN2Line(totalMaxDepth), + + // ppHe line (if showing and has helium data) + if (_showPpHe && + widget.ppHeCurve != null && + widget.ppHeCurve!.any((v) => v > 0.001)) + _buildPpHeLine(totalMaxDepth), + + // MOD line (if showing) + if (_showMod && widget.modCurve != null) _buildModLine(units), + + // Gas density line (if showing) + if (_showDensity && widget.densityCurve != null) + _buildDensityLine(totalMaxDepth), + + // GF% line (if showing) + if (_showGf && widget.gfCurve != null) + _buildGfLine(totalMaxDepth), + + // Surface GF line (if showing) + if (_showSurfaceGf && widget.surfaceGfCurve != null) + _buildSurfaceGfLine(totalMaxDepth), + + // Mean depth line (if showing) + if (_showMeanDepth && widget.meanDepthCurve != null) + _buildMeanDepthLine(units), + + // TTS line (if showing) + if (_showTts && widget.ttsCurve != null) + _buildTtsLine(totalMaxDepth), + + // CNS% curve (if showing) + if (_showCns && widget.cnsCurve != null) + _buildCnsLine(totalMaxDepth), + + // OTU curve (if showing) + if (_showOtu && widget.otuCurve != null) + _buildOtuLine(totalMaxDepth), + + // Profile markers (max depth, pressure thresholds) + ..._buildMarkerLines( + units, + totalMaxDepth, + minPressure: minPressure, + maxPressure: maxPressure, + ), + ], + extraLinesData: ExtraLinesData( + verticalLines: [ + ..._buildPlaybackCursor(colorScheme), + ..._buildHighlightCursor(colorScheme), + if (_showEvents && widget.events != null) + ..._buildEventVerticalLines(colorScheme), + ], + ), + lineTouchData: LineTouchData( + enabled: true, + touchSpotThreshold: 20, + handleBuiltInTouches: true, + touchCallback: (event, response) { + if (widget.onPointSelected != null || + widget.onTooltipData != null) { + if (event is FlPointerExitEvent || + event is FlLongPressEnd || + event is FlTapUpEvent || + event is FlPanEndEvent) { + widget.onPointSelected?.call(null); + if (widget.tooltipBelow) { + widget.onTooltipData?.call(null); + } + } else if (response?.lineBarSpots != null && + response!.lineBarSpots!.isNotEmpty) { + final spot = response.lineBarSpots!.first; + if (spot.barIndex == 0 && + spot.spotIndex < widget.profile.length) { + widget.onPointSelected?.call(spot.spotIndex); + if (widget.tooltipBelow) { + final settings = ref.read(settingsProvider); + final units = UnitFormatter(settings); + _emitExternalTooltip( + response.lineBarSpots!, + units, + Theme.of(context).colorScheme, + ); + } + } + } + } + }, + touchTooltipData: LineTouchTooltipData( + maxContentWidth: 220, + fitInsideHorizontally: true, + fitInsideVertically: false, + showOnTopOfTheChartBoxArea: true, + tooltipMargin: 0, + getTooltipColor: widget.tooltipBelow + ? (_) => Colors.transparent + : (spot) => colorScheme.inverseSurface, + getTooltipItems: (touchedSpots) { + // When tooltipBelow, suppress the visual bubble. + // Tooltip data is emitted via touchCallback instead. + if (widget.tooltipBelow) { + return touchedSpots.map((_) => null).toList(); + } + // Return cached result if the same spot index is touched again + if (touchedSpots.isNotEmpty) { + final firstDepthSpot = touchedSpots + .where((s) => s.barIndex == 0) + .firstOrNull; + if (firstDepthSpot != null && + firstDepthSpot.spotIndex == _lastTooltipSpotIndex) { + return _lastTooltipItems; + } + } + + // Build tooltip showing all enabled metrics for the touched point + // Only process the depth line (barIndex 0) and build combined tooltip + final result = touchedSpots.map((spot) { + final isDepth = spot.barIndex == 0; + if (!isDepth) { + return null; + } + + final point = widget.profile[spot.spotIndex]; + final minutes = point.timestamp ~/ 60; + final seconds = point.timestamp % 60; + + // Build tooltip with all enabled metrics + final lines = []; + + // Text style constants for consistent column layout + final onSurface = colorScheme.onInverseSurface; + final rowStyle = TextStyle( + fontFamily: 'RobotoMono', + fontSize: 14, + color: onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ); + + const labelWidth = 8; + const valueWidth = 16; + const rowWidth = labelWidth + valueWidth; + final rowFiller = List.filled(rowWidth, '0').join(); + + String clampText(String text, int maxChars) { + if (text.length <= maxChars) { + return text; + } + return text.substring(0, maxChars); + } + + // Helper to add a formatted row with constant width + void addRow( + String label, + String value, + Color bulletColor, { + String bullet = '●', + double bulletSize = 12, + }) { + if (lines.isNotEmpty) { + lines.add(const TextSpan(text: '\n')); + } + lines.add( + TextSpan( + text: '$bullet ', + style: TextStyle( + color: bulletColor, + fontSize: bulletSize, + ), + ), + ); + final labelText = clampText( + label, + labelWidth, + ).padRight(labelWidth); + final valueText = clampText( + value, + valueWidth, + ).padRight(valueWidth); + final rowText = (labelText + valueText).trimRight(); + lines.add(TextSpan(text: rowText, style: rowStyle)); + + final fillerCount = rowWidth - rowText.length; + if (fillerCount > 0) { + lines.add( + TextSpan( + text: rowFiller.substring(0, fillerCount), + style: rowStyle.copyWith(color: Colors.transparent), + ), + ); + } + } + + // Time (always shown) + final timeValue = + '$minutes:${seconds.toString().padLeft(2, '0')}'; + addRow( + context.l10n.diveLog_tooltip_time, + timeValue, + onSurface.withValues(alpha: 0.5), + ); + + // Depth (always shown) - use same color as depth line + addRow( + context.l10n.diveLog_tooltip_depth, + units.formatDepth(point.depth), + AppColors.chartDepth, + ); + + // Temperature (if enabled - always show row) + if (_showTemperature) { + final tempValue = point.temperature != null + ? units.formatTemperature(point.temperature) + : '—'; + addRow( + context.l10n.diveLog_tooltip_temp, + tempValue, + colorScheme.tertiary, + ); + } + + // Heart rate (if enabled - always show row) + if (_showHeartRate) { + final hrValue = point.heartRate != null + ? '${point.heartRate} bpm' + : '—'; + addRow( + context.l10n.diveLog_tooltip_hr, + hrValue, + Colors.red, + ); + } + + // SAC (if enabled - always show row) + if (_showSac) { + String sacValue = '—'; + if (widget.sacCurve != null && + spot.spotIndex < widget.sacCurve!.length) { + final sacBarPerMin = widget.sacCurve![spot.spotIndex]; + if (sacBarPerMin > 0) { + final normalizedSac = + sacBarPerMin * widget.sacNormalizationFactor; + if (sacUnit == SacUnit.litersPerMin && + widget.tankVolume != null) { + final sacLPerMin = + normalizedSac * widget.tankVolume!; + sacValue = + '${units.convertVolume(sacLPerMin).toStringAsFixed(1)} ${units.volumeSymbol}/min'; + } else { + sacValue = + '${units.convertPressure(normalizedSac).toStringAsFixed(1)} ${units.pressureSymbol}/min'; + } + } + } + addRow( + context.l10n.diveLog_tooltip_sac, + sacValue, + Colors.teal, + ); + } + + // Ceiling (if enabled - always show row) + if (_showCeiling) { + String ceilingValue = '—'; + if (widget.ceilingCurve != null && + spot.spotIndex < widget.ceilingCurve!.length) { + final ceiling = widget.ceilingCurve![spot.spotIndex]; + if (ceiling > 0) { + ceilingValue = units.formatDepth(ceiling); + } + } + addRow( + context.l10n.diveLog_tooltip_ceiling, + ceilingValue, + const Color(0xFFD32F2F), + ); + } + + // Ascent rate (if enabled - always show row with fixed format) + // Uses distinct colors that don't conflict with gas colors: + // - Descent: cyan (distinct from air blue) + // - Safe ascent: lime green (distinct from nitrox green) + // - Warning/danger: orange/red (already distinct) + if (_showAscentRateColors) { + Color rateColor = Colors.grey; + String arrow = '—'; + double convertedRate = 0.0; + + if (widget.ascentRates != null && + spot.spotIndex < widget.ascentRates!.length) { + final ascentRate = widget.ascentRates![spot.spotIndex]; + final rate = ascentRate.rateMetersPerMin; + convertedRate = units.convertDepth(rate.abs()); + if (rate > 0.5) { + arrow = '↑'; + // Use lime for safe ascent (distinct from nitrox green) + rateColor = + ascentRate.category == AscentRateCategory.safe + ? Colors.lime + : _getAscentRateColor(ascentRate.category); + } else if (rate < -0.5) { + arrow = '↓'; + // Use cyan for descent (distinct from air blue) + rateColor = Colors.cyan; + } + } + final rateNum = convertedRate + .toStringAsFixed(1) + .padLeft(5); + final rateValue = + '$arrow$rateNum ${units.depthSymbol}/min'; + addRow( + context.l10n.diveLog_tooltip_rate, + rateValue, + rateColor, + ); + } + + // NDL (if enabled) + if (_showNdl) { + String ndlValue = '—'; + if (widget.ndlCurve != null && + spot.spotIndex < widget.ndlCurve!.length) { + final ndl = widget.ndlCurve![spot.spotIndex]; + if (ndl < 0) { + ndlValue = context.l10n.diveLog_playbackStats_deco; + } else if (ndl < 3600) { + final min = ndl ~/ 60; + final sec = ndl % 60; + ndlValue = '$min:${sec.toString().padLeft(2, '0')}'; + } else { + ndlValue = '>60 min'; + } + } + addRow( + context.l10n.diveLog_tooltip_ndl, + ndlValue, + Colors.yellow.shade700, + ); + } + + // ppO2 (if enabled) + if (_showPpO2) { + String ppO2Value = '—'; + if (widget.ppO2Curve != null && + spot.spotIndex < widget.ppO2Curve!.length) { + final ppO2 = widget.ppO2Curve![spot.spotIndex]; + ppO2Value = '${ppO2.toStringAsFixed(2)} bar'; + } + addRow( + context.l10n.diveLog_tooltip_ppO2, + ppO2Value, + const Color(0xFF00ACC1), + ); + } + + // ppN2 (if enabled) + if (_showPpN2) { + String ppN2Value = '—'; + if (widget.ppN2Curve != null && + spot.spotIndex < widget.ppN2Curve!.length) { + final ppN2 = widget.ppN2Curve![spot.spotIndex]; + ppN2Value = '${ppN2.toStringAsFixed(2)} bar'; + } + addRow( + context.l10n.diveLog_tooltip_ppN2, + ppN2Value, + Colors.indigo, + ); + } + + // ppHe (if enabled) + if (_showPpHe) { + String ppHeValue = '—'; + if (widget.ppHeCurve != null && + spot.spotIndex < widget.ppHeCurve!.length) { + final ppHe = widget.ppHeCurve![spot.spotIndex]; + if (ppHe > 0.001) { + ppHeValue = '${ppHe.toStringAsFixed(2)} bar'; + } + } + addRow( + context.l10n.diveLog_tooltip_ppHe, + ppHeValue, + Colors.pink.shade300, + ); + } + + // MOD (if enabled) + if (_showMod) { + String modValue = '—'; + if (widget.modCurve != null && + spot.spotIndex < widget.modCurve!.length) { + final mod = widget.modCurve![spot.spotIndex]; + if (mod > 0 && mod < 200) { + modValue = units.formatDepth(mod); + } + } + addRow( + context.l10n.diveLog_tooltip_mod, + modValue, + Colors.deepOrange, + ); + } + + // Gas density (if enabled) + if (_showDensity) { + String densityValue = '—'; + if (widget.densityCurve != null && + spot.spotIndex < widget.densityCurve!.length) { + final density = widget.densityCurve![spot.spotIndex]; + densityValue = '${density.toStringAsFixed(2)} g/L'; + } + addRow( + context.l10n.diveLog_tooltip_density, + densityValue, + Colors.brown, + ); + } + + // GF% (if enabled) + if (_showGf) { + String gfValue = '—'; + if (widget.gfCurve != null && + spot.spotIndex < widget.gfCurve!.length) { + final gf = widget.gfCurve![spot.spotIndex]; + gfValue = '${gf.toStringAsFixed(0)}%'; + } + addRow( + context.l10n.diveLog_tooltip_gfPercent, + gfValue, + Colors.deepPurple, + ); + } + + // Surface GF (if enabled) + if (_showSurfaceGf) { + String surfaceGfValue = '—'; + if (widget.surfaceGfCurve != null && + spot.spotIndex < widget.surfaceGfCurve!.length) { + final surfaceGf = + widget.surfaceGfCurve![spot.spotIndex]; + surfaceGfValue = '${surfaceGf.toStringAsFixed(0)}%'; + } + addRow( + context.l10n.diveLog_tooltip_srfGf, + surfaceGfValue, + Colors.purple.shade300, + ); + } + + // Mean depth (if enabled) + if (_showMeanDepth) { + String meanDepthValue = '—'; + if (widget.meanDepthCurve != null && + spot.spotIndex < widget.meanDepthCurve!.length) { + final meanDepth = + widget.meanDepthCurve![spot.spotIndex]; + meanDepthValue = units.formatDepth(meanDepth); + } + addRow( + context.l10n.diveLog_tooltip_mean, + meanDepthValue, + Colors.blueGrey, + ); + } + + // TTS (if enabled) + if (_showTts) { + String ttsValue = '—'; + if (widget.ttsCurve != null && + spot.spotIndex < widget.ttsCurve!.length) { + final tts = widget.ttsCurve![spot.spotIndex]; + if (tts > 0) { + final min = (tts / 60).ceil(); + ttsValue = '$min min'; + } else { + ttsValue = '0 min'; + } + } + addRow( + context.l10n.diveLog_tooltip_tts, + ttsValue, + const Color(0xFFAD1457), + ); + } + + // CNS% (if enabled) + if (_showCns) { + String cnsValue = '\u2014'; + if (widget.cnsCurve != null && + spot.spotIndex < widget.cnsCurve!.length) { + final cns = widget.cnsCurve![spot.spotIndex]; + cnsValue = '${cns.toStringAsFixed(1)}%'; + } + addRow( + context.l10n.diveLog_tooltip_cns, + cnsValue, + const Color(0xFFE65100), + ); + } + + // OTU (if enabled) + if (_showOtu) { + String otuValue = '\u2014'; + if (widget.otuCurve != null && + spot.spotIndex < widget.otuCurve!.length) { + final otu = widget.otuCurve![spot.spotIndex]; + otuValue = otu.toStringAsFixed(0); + } + addRow( + context.l10n.diveLog_tooltip_otu, + otuValue, + const Color(0xFF6D4C41), + ); + } + + // Per-tank pressure (if any tanks are enabled) + if (widget.tankPressures != null) { + final timestamp = point.timestamp; + final sortedTankIds = _sortedTankIds( + widget.tankPressures!.keys, + ); + + for (var i = 0; i < sortedTankIds.length; i++) { + final tankId = sortedTankIds[i]; + if (!(_showTankPressure[tankId] ?? true)) continue; + + final pressurePoints = widget.tankPressures![tankId]; + if (pressurePoints == null || pressurePoints.isEmpty) { + continue; + } + + final pressure = _interpolateTankPressure( + pressurePoints, + timestamp, + ); + final tank = _getTankById(tankId); + final color = tank != null + ? GasColors.forGasMix(tank.gasMix) + : _getTankColor(i); + final tankLabel = + tank?.name ?? + context.l10n.diveLog_tank_title(i + 1); + final pressValue = pressure != null + ? units.formatPressure(pressure) + : '—'; + addRow(tankLabel, pressValue, color); + } + } + + // Marker info (if touching near a marker) + final markers = widget.markers; + if (markers != null && markers.isNotEmpty) { + final timestamp = point.timestamp; + const timestampThreshold = 3; + + for (final marker in markers) { + if (marker.type == ProfileMarkerType.maxDepth) { + if (!widget.showMaxDepthMarker || + !_showMaxDepthMarkerLocal) { + continue; + } + } else { + if (!widget.showPressureThresholdMarkers || + !_showPressureMarkersLocal) { + continue; + } + } + + if ((marker.timestamp - timestamp).abs() <= + timestampThreshold) { + final markerColor = marker.getColor(); + addRow( + context.l10n.diveLog_tooltip_marker, + marker.chartLabel, + markerColor, + bullet: '◆', + bulletSize: 10, + ); + } + } + } + + return LineTooltipItem( + '', // Empty base text, using children instead + TextStyle(color: onSurface), + children: lines, + textAlign: TextAlign.start, + ); + }).toList(); + + // Cache the result for next frame + final depthSpot = touchedSpots + .where((s) => s.barIndex == 0) + .firstOrNull; + if (depthSpot != null) { + _lastTooltipSpotIndex = depthSpot.spotIndex; + _lastTooltipItems = result; + } + + return result; + }, + ), + ), + ), + ), + // Right axis tap overlay for metric selection + if (effectiveRightAxisMetric != null) + Positioned( + right: 0, + top: 0, + bottom: 30, // Leave space for bottom axis + width: 50, // Match reservedSize of right axis + child: Semantics( + button: true, + label: context.l10n.diveLog_profile_semantics_changeRightAxis, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => _showRightAxisMetricSelector( + context, + colorScheme, + effectiveRightAxisMetric, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container(color: Colors.transparent), + ), + ), + ), + ), + // Gas-usage timeline strip rendered between the plot area and the + // X-axis tick labels. Sized to exactly the chart's plot width by + // mirroring the chart's left/right axis reservations, and offset + // from the bottom so it lands in the gap reserved above by + // `_hasGasStrip` (_bottomAxisNameSize + _bottomTickReservedSize). + // + // Plot bounds = _leftRightAxisNameSize + sideTitles reservedSize + // on each side that has an axisNameWidget. Left axis always renders + // its name; the right axis only does so when a metric is selected. + if (_hasGasStrip) + Positioned( + left: + DiveProfileChart._leftRightAxisNameSize + + DiveProfileChart.leftAxisSize(availableWidth), + right: + (effectiveRightAxisMetric != null && rightAxisRange != null + ? DiveProfileChart._leftRightAxisNameSize + : 0) + + DiveProfileChart.rightAxisSize(availableWidth), + bottom: + DiveProfileChart._bottomAxisNameSize + + DiveProfileChart._bottomTickReservedSize, + height: DiveProfileChart.gasTimelineHeight, + child: GasTimelineStrip( + segments: widget.gasSegments!, + diveDurationSeconds: widget.diveDurationSeconds!, + height: DiveProfileChart.gasTimelineHeight, + leftPadding: 0, + rightPadding: 0, + visibleMinSeconds: visibleMinX, + visibleMaxSeconds: visibleMaxX, + ), + ), + // Extension of the hover/playback cursor line into the gas strip. + // fl_chart's vertical lines are clipped to the plot area, so the + // strip would otherwise miss the cursor; we draw a 1-px line at + // the same horizontal position to bridge the gap visually. + if (_hasGasStrip) + ..._buildGasStripCursorExtensions( + availableWidth: availableWidth, + visibleMinX: visibleMinX, + visibleMaxX: visibleMaxX, + hasRightAxisName: + effectiveRightAxisMetric != null && rightAxisRange != null, + ), + ], + ); + } + + /// Builds vertical line extensions over the gas timeline strip for any + /// active cursors (hover highlight + step-through playback) so the line + /// visually continues past the chart's plot area. + List _buildGasStripCursorExtensions({ + required double availableWidth, + required double visibleMinX, + required double visibleMaxX, + required bool hasRightAxisName, + }) { + final colorScheme = Theme.of(context).colorScheme; + final cursors = <(int timestamp, Color color, double width)>[ + if (widget.highlightedTimestamp != null) + ( + widget.highlightedTimestamp!, + colorScheme.onSurface.withValues(alpha: 0.5), + 1.0, + ), + if (widget.playbackTimestamp != null) + (widget.playbackTimestamp!, colorScheme.primary, 2.0), + ]; + if (cursors.isEmpty) return const []; + + final left = + DiveProfileChart._leftRightAxisNameSize + + DiveProfileChart.leftAxisSize(availableWidth); + final right = + (hasRightAxisName ? DiveProfileChart._leftRightAxisNameSize : 0) + + DiveProfileChart.rightAxisSize(availableWidth); + final stripWidth = (availableWidth - left - right).clamp( + 0.0, + double.infinity, + ); + final visibleRangeX = visibleMaxX - visibleMinX; + if (visibleRangeX <= 0 || stripWidth <= 0) return const []; + + return [ + for (final (timestamp, color, width) in cursors) + if (timestamp >= visibleMinX && timestamp <= visibleMaxX) + Positioned( + left: + left + + ((timestamp - visibleMinX) / visibleRangeX) * stripWidth - + width / 2, + bottom: + DiveProfileChart._bottomAxisNameSize + + DiveProfileChart._bottomTickReservedSize, + height: DiveProfileChart.gasTimelineHeight, + width: width, + child: IgnorePointer(child: ColoredBox(color: color)), + ), + ]; + } + + /// Show popup menu for selecting right axis metric + void _showRightAxisMetricSelector( + BuildContext context, + ColorScheme colorScheme, + ProfileRightAxisMetric currentMetric, + ) { + final legendNotifier = ref.read(profileLegendProvider.notifier); + + // Build list of metrics grouped by category + final menuItems = >[]; + + // Add "None" option to hide the axis. + // Use onTap instead of relying on the menu return value, because + // showMenu returns null both for "None" (value: null) and for + // dismissing the menu — we can't distinguish them otherwise. + menuItems.add( + PopupMenuItem( + value: null, + onTap: () => legendNotifier.hideRightAxis(), + child: Row( + children: [ + Icon( + Icons.visibility_off, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text(context.l10n.diveLog_profile_rightAxis_none), + ], + ), + ), + ); + menuItems.add(const PopupMenuDivider()); + + // Group metrics by category + for (final category in ProfileMetricCategory.values) { + final metricsInCategory = category.metrics; + final availableMetrics = metricsInCategory + .where((m) => _hasDataForMetric(m)) + .toList(); + + if (availableMetrics.isEmpty) continue; + + // Add divider before category (except first) + if (menuItems.length > 2) { + menuItems.add(const PopupMenuDivider()); + } + + // Add category header + menuItems.add( + PopupMenuItem( + enabled: false, + height: 32, + child: Text( + category.displayName, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + + // Add metrics in this category + for (final metric in availableMetrics) { + final isSelected = metric == currentMetric; + final metricColor = metric.getColor(colorScheme); + + menuItems.add( + PopupMenuItem( + value: metric, + child: Row( + children: [ + Icon( + isSelected ? Icons.check : Icons.show_chart, + size: 16, + color: isSelected + ? metricColor + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Container( + width: 12, + height: 3, + decoration: BoxDecoration( + color: metricColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + metric.displayName, + style: TextStyle( + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ], + ), + ), + ); + } + } + + // Show the popup menu + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final offset = renderBox.localToGlobal(Offset.zero); + + showMenu( + context: context, + position: RelativeRect.fromLTRB( + offset.dx + renderBox.size.width - 200, + offset.dy, + offset.dx + renderBox.size.width, + offset.dy + renderBox.size.height, + ), + items: menuItems, + ).then((selectedMetric) { + // "None" is handled via onTap on its PopupMenuItem. + // Here we only handle actual metric selections (non-null). + if (selectedMetric != null) { + legendNotifier.setRightAxisMetric(selectedMetric); + } + }); + } + + /// Build depth line segments. + /// + /// When [widget.computerProfiles] is provided with 2+ entries, draws one + /// depth curve per visible computer using its assigned color. Primary + /// computers get a solid line; secondaries get a dashed line. + /// Falls back to single-profile rendering when multi-computer data is absent. + List _buildGasColoredDepthLines( + ColorScheme colorScheme, + UnitFormatter units, + ) { + final cpProfiles = widget.computerProfiles; + if (cpProfiles != null && cpProfiles.length >= 2) { + return _buildMultiComputerDepthLines(cpProfiles, units); + } + const depthColor = AppColors.chartDepth; + return [ + _buildSingleDepthSegment( + depthColor, + units, + 0, + widget.profile.length, + showFill: true, + ), + ]; + } + + /// Build one depth line per computer for multi-computer rendering. + List _buildMultiComputerDepthLines( + Map> cpProfiles, + UnitFormatter units, + ) { + final lines = []; + var index = 0; + for (final entry in cpProfiles.entries) { + final computerId = entry.key; + final points = entry.value; + + // Skip computers that have been toggled off. + final visible = widget.visibleComputers; + if (visible != null && !visible.contains(computerId)) { + index++; + continue; + } + + final color = + widget.computerLineColors?[computerId] ?? _computerColorAt(index); + final isPrimary = + widget.primaryComputers?.contains(computerId) ?? index == 0; + + final spots = points + .map( + (p) => FlSpot(p.timestamp.toDouble(), -units.convertDepth(p.depth)), + ) + .toList(); + + if (isPrimary) { + // Solid line with fill for the primary computer. + lines.add( + LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: color, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: GasColors.gradientColors(color), + ), + ), + ), + ); + } else { + // Dashed line (no fill) for secondary computers. + lines.add( + LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: color, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: const [6, 4], + belowBarData: BarAreaData(show: false), + ), + ); + } + + index++; + } + return lines; + } + + /// Returns a color for a computer at the given index. + /// Delegates to the shared [computerColorAt] in computer_toggle_bar.dart. + Color _computerColorAt(int index) => computerColorAt(index); + + /// Build a single depth line segment with the given color + LineChartBarData _buildSingleDepthSegment( + Color color, + UnitFormatter units, + int startIndex, + int endIndex, { + bool showFill = false, + }) { + return LineChartBarData( + spots: widget.profile + .sublist(startIndex, endIndex) + .map( + (p) => FlSpot(p.timestamp.toDouble(), -units.convertDepth(p.depth)), + ) + .toList(), + isCurved: true, + curveSmoothness: 0.2, + color: color, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: showFill + ? BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: GasColors.gradientColors(color), + ), + ) + : BarAreaData(show: false), + ); + } + + /// Build gas switch marker dots on the profile + List _buildGasSwitchMarkers(UnitFormatter units) { + final gasSwitches = widget.gasSwitches; + if (gasSwitches == null || gasSwitches.isEmpty) { + return []; + } + + return gasSwitches.map((gs) { + final color = GasColors.forMixFraction(gs.o2Fraction, gs.heFraction); + + // Find the depth at this timestamp from profile + final depth = gs.depth ?? _findDepthAtTimestamp(gs.timestamp); + + return LineChartBarData( + spots: [FlSpot(gs.timestamp.toDouble(), -units.convertDepth(depth))], + isCurved: false, + color: Colors.transparent, + barWidth: 0, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, bar, index) { + return FlDotCirclePainter( + radius: 6, + color: color, + strokeWidth: 2, + strokeColor: Colors.white, + ); + }, + ), + ); + }).toList(); + } + + /// Find the depth at a given timestamp by interpolating profile data + double _findDepthAtTimestamp(int timestamp) { + if (widget.profile.isEmpty) return 0; + + // Find the closest profile point + for (int i = 0; i < widget.profile.length; i++) { + if (widget.profile[i].timestamp >= timestamp) { + if (i == 0) return widget.profile[0].depth; + // Simple interpolation + final prev = widget.profile[i - 1]; + final curr = widget.profile[i]; + final ratio = + (timestamp - prev.timestamp) / (curr.timestamp - prev.timestamp); + return prev.depth + (curr.depth - prev.depth) * ratio; + } + } + return widget.profile.last.depth; + } + + LineChartBarData _buildTemperatureLine( + ColorScheme colorScheme, + double chartMaxDepth, + double minTemp, + double maxTemp, + UnitFormatter units, + ) { + return LineChartBarData( + spots: widget.profile + .where((p) => p.temperature != null) + .map( + (p) => FlSpot( + p.timestamp.toDouble(), + // Convert temp to user's unit, then map to depth axis + -_mapTempToDepth( + units.convertTemperature(p.temperature!), + chartMaxDepth, + minTemp, + maxTemp, + ), + ), + ) + .toList(), + isCurved: true, + curveSmoothness: 0.2, + color: colorScheme.tertiary, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [5, 3], + ); + } + + /// Build multiple pressure lines for multi-tank visualization + List _buildMultiTankPressureLines(double chartMaxDepth) { + if (!_hasMultiTankPressure) return []; + + final tankPressures = widget.tankPressures!; + final lines = []; + + // Calculate global min/max pressure across all tanks for consistent scaling + double? globalMinPressure; + double? globalMaxPressure; + + for (final pressurePoints in tankPressures.values) { + for (final point in pressurePoints) { + if (globalMinPressure == null || point.pressure < globalMinPressure) { + globalMinPressure = point.pressure; + } + if (globalMaxPressure == null || point.pressure > globalMaxPressure) { + globalMaxPressure = point.pressure; + } + } + } + + if (globalMinPressure == null || globalMaxPressure == null) return []; + + // Add some padding to the pressure range + final pressureRange = globalMaxPressure - globalMinPressure; + final minPressure = globalMinPressure - (pressureRange * 0.05); + final maxPressure = globalMaxPressure + (pressureRange * 0.05); + + final sortedTankIds = _sortedTankIds(tankPressures.keys); + + // Build a line for each visible tank + for (var i = 0; i < sortedTankIds.length; i++) { + final tankId = sortedTankIds[i]; + + // Skip if tank is hidden + if (_showTankPressure[tankId] == false) continue; + + final pressurePoints = tankPressures[tankId]!; + if (pressurePoints.isEmpty) continue; + + // Get tank for color + final tank = _getTankById(tankId); + + // Use gas color or fallback + final color = tank != null + ? GasColors.forGasMix(tank.gasMix) + : _getTankColor(i); + final dashPattern = _getTankDashPattern(i); + + lines.add( + LineChartBarData( + spots: pressurePoints + .map( + (p) => FlSpot( + p.timestamp.toDouble(), + -_mapValueToDepth( + p.pressure, + chartMaxDepth, + minPressure, + maxPressure, + ), + ), + ) + .toList(), + isCurved: true, + curveSmoothness: 0.2, + color: color, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: dashPattern, + ), + ); + } + + return lines; + } + + LineChartBarData _buildHeartRateLine( + Color color, + double chartMaxDepth, + double minHR, + double maxHR, + ) { + return LineChartBarData( + spots: widget.profile + .where((p) => p.heartRate != null) + .map( + (p) => FlSpot( + p.timestamp.toDouble(), + -_mapValueToDepth( + p.heartRate!.toDouble(), + chartMaxDepth, + minHR, + maxHR, + ), + ), + ) + .toList(), + isCurved: true, + curveSmoothness: 0.2, + color: color, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [3, 2], + ); + } + + /// Build SAC (Surface Air Consumption) curve line + LineChartBarData _buildSacLine( + double chartMaxDepth, + double minSac, + double maxSac, + ) { + const sacColor = Colors.teal; + final sacCurve = widget.sacCurve!; + + // Build spots for each profile point that has SAC data + final spots = []; + for (int i = 0; i < widget.profile.length && i < sacCurve.length; i++) { + final sac = sacCurve[i]; + if (sac > 0) { + spots.add( + FlSpot( + widget.profile[i].timestamp.toDouble(), + -_mapValueToDepth(sac, chartMaxDepth, minSac, maxSac), + ), + ); + } + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.3, + color: sacColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [6, 3], // Distinctive dash pattern for SAC + ); + } + + // Map temperature value to depth axis for overlay + double _mapTempToDepth( + double temp, + double maxDepth, + double minTemp, + double maxTemp, + ) { + final normalized = (temp - minTemp) / (maxTemp - minTemp); + return maxDepth * (1 - normalized); // Higher temp maps to shallower depth + } + + // Generic value to depth axis mapping + double _mapValueToDepth( + double value, + double maxDepth, + double minValue, + double maxValue, + ) { + final normalized = (value - minValue) / (maxValue - minValue); + return maxDepth * (1 - normalized); + } + + double _calculateDepthInterval(double maxDepth) { + if (maxDepth <= 10) return 2; + if (maxDepth <= 20) return 5; + if (maxDepth <= 50) return 10; + return 20; + } + + double _calculateTimeInterval(double maxTime) { + final minutes = maxTime / 60; + if (minutes <= 10) return 60; // 1 min intervals + if (minutes <= 30) return 300; // 5 min intervals + if (minutes <= 60) return 600; // 10 min intervals + return 900; // 15 min intervals + } + + /// Build the ceiling line (decompression ceiling) + LineChartBarData _buildCeilingLine(UnitFormatter units) { + final ceilingData = widget.ceilingCurve!; + const ceilingColor = Color( + 0xFFD32F2F, + ); // Red 700 - distinct from pressure orange + + // Build spots only where ceiling > 0 + final spots = []; + for (int i = 0; i < widget.profile.length && i < ceilingData.length; i++) { + final ceiling = ceilingData[i]; + if (ceiling > 0) { + spots.add( + FlSpot( + widget.profile[i].timestamp.toDouble(), + -units.convertDepth( + ceiling, + ), // Convert and negate for inverted axis + ), + ); + } + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: ceilingColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [4, 4], + belowBarData: BarAreaData( + show: true, + color: ceilingColor.withValues(alpha: 0.15), + cutOffY: 0, // Fill to surface + applyCutOffY: true, + ), + ); + } + + /// Build NDL (No Decompression Limit) line + /// NDL values are in seconds; shows time remaining before deco obligation + LineChartBarData _buildNdlLine(double chartMaxDepth) { + final ndlData = widget.ndlCurve!; + final ndlColor = Colors.yellow.shade700; + + // Map NDL to chart: max NDL (~60 min) at top, 0 at bottom + const maxNdlSeconds = 3600.0; // 60 minutes as max display + + final spots = []; + for (int i = 0; i < widget.profile.length && i < ndlData.length; i++) { + // Clamp NDL to display range to avoid gaps that cause Bezier artifacts. + // Negative values (in deco) clamp to 0; values > 60 min clamp to 60 min. + final ndl = ndlData[i].clamp(0, maxNdlSeconds.toInt()).toDouble(); + final normalized = ndl / maxNdlSeconds; + final yValue = chartMaxDepth * (1 - normalized); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + preventCurveOverShooting: true, + color: ndlColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [6, 3], + ); + } + + /// Build ppO2 (partial pressure of oxygen) line + /// Values typically range from 0.21 (surface air) to 1.6+ (critical) + LineChartBarData _buildPpO2Line(double chartMaxDepth) { + final ppO2Data = widget.ppO2Curve!; + const ppO2Color = Color(0xFF00ACC1); // Cyan 600 - distinct from depth blue + + // Map ppO2 to chart: 0 at top, 2.0 bar at bottom + const minPpO2 = 0.0; + const maxPpO2 = 2.0; + + final spots = []; + for (int i = 0; i < widget.profile.length && i < ppO2Data.length; i++) { + final ppO2 = ppO2Data[i].clamp(minPpO2, maxPpO2); + final yValue = _mapValueToDepth(ppO2, chartMaxDepth, minPpO2, maxPpO2); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: ppO2Color, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [5, 3], + ); + } + + /// Build ppN2 (partial pressure of nitrogen) line + LineChartBarData _buildPpN2Line(double chartMaxDepth) { + final ppN2Data = widget.ppN2Curve!; + const ppN2Color = Colors.indigo; + + // Map ppN2 to chart: 0 at top, ~5 bar at bottom (deep dive) + const minPpN2 = 0.0; + const maxPpN2 = 5.0; + + final spots = []; + for (int i = 0; i < widget.profile.length && i < ppN2Data.length; i++) { + final ppN2 = ppN2Data[i].clamp(minPpN2, maxPpN2); + final yValue = _mapValueToDepth(ppN2, chartMaxDepth, minPpN2, maxPpN2); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: ppN2Color, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [4, 2], + ); + } + + /// Build ppHe (partial pressure of helium) line for trimix dives + LineChartBarData _buildPpHeLine(double chartMaxDepth) { + final ppHeData = widget.ppHeCurve!; + final ppHeColor = Colors.pink.shade300; + + // Map ppHe to chart: 0 at top, ~3 bar at bottom + const minPpHe = 0.0; + const maxPpHe = 3.0; + + final spots = []; + for (int i = 0; i < widget.profile.length && i < ppHeData.length; i++) { + final ppHe = ppHeData[i]; + if (ppHe > 0.001) { + final clamped = ppHe.clamp(minPpHe, maxPpHe); + final yValue = _mapValueToDepth( + clamped, + chartMaxDepth, + minPpHe, + maxPpHe, + ); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: ppHeColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [3, 3], + ); + } + + /// Build MOD (Maximum Operating Depth) line + /// Shows the MOD limit as a horizontal reference line + LineChartBarData _buildModLine(UnitFormatter units) { + final modData = widget.modCurve!; + const modColor = Colors.deepOrange; + + // MOD is typically constant for a given gas + final spots = []; + for (int i = 0; i < widget.profile.length && i < modData.length; i++) { + final mod = modData[i]; + if (mod > 0 && mod < 200) { + spots.add( + FlSpot( + widget.profile[i].timestamp.toDouble(), + -units.convertDepth(mod), + ), + ); + } + } + + return LineChartBarData( + spots: spots, + isCurved: false, + color: modColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [8, 4], + ); + } + + /// Build gas density line (g/L) + /// High density (>5.7 g/L) increases work of breathing + LineChartBarData _buildDensityLine(double chartMaxDepth) { + final densityData = widget.densityCurve!; + const densityColor = Colors.brown; + + // Map density to chart: 0 at top, 8 g/L at bottom + const minDensity = 0.0; + const maxDensity = 8.0; + + final spots = []; + for (int i = 0; i < widget.profile.length && i < densityData.length; i++) { + final density = densityData[i].clamp(minDensity, maxDensity); + final yValue = _mapValueToDepth( + density, + chartMaxDepth, + minDensity, + maxDensity, + ); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: densityColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [5, 2], + ); + } + + /// Build GF% (Gradient Factor percentage) line at current depth + /// Shows how close tissues are to M-value limit + LineChartBarData _buildGfLine(double chartMaxDepth) { + final gfData = widget.gfCurve!; + const gfColor = Colors.deepPurple; + + // Map GF% to chart: 0% at top, 120% at bottom + const minGf = 0.0; + const maxGf = 120.0; + + final spots = []; + for (int i = 0; i < widget.profile.length && i < gfData.length; i++) { + final gf = gfData[i].clamp(minGf, maxGf); + final yValue = _mapValueToDepth(gf, chartMaxDepth, minGf, maxGf); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: gfColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [4, 3], + ); + } + + /// Build Surface GF% line (what GF would be if surfaced now) + /// Values >100% indicate deco obligation + LineChartBarData _buildSurfaceGfLine(double chartMaxDepth) { + final surfaceGfData = widget.surfaceGfCurve!; + final surfaceGfColor = Colors.purple.shade300; + + // Map Surface GF% to chart: 0% at top, 150% at bottom + const minGf = 0.0; + const maxGf = 150.0; + + final spots = []; + for ( + int i = 0; + i < widget.profile.length && i < surfaceGfData.length; + i++ + ) { + final gf = surfaceGfData[i].clamp(minGf, maxGf); + final yValue = _mapValueToDepth(gf, chartMaxDepth, minGf, maxGf); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: surfaceGfColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [6, 2], + ); + } + + /// Build mean depth line (running average from start) + LineChartBarData _buildMeanDepthLine(UnitFormatter units) { + final meanDepthData = widget.meanDepthCurve!; + const meanDepthColor = Colors.blueGrey; + + final spots = []; + for ( + int i = 0; + i < widget.profile.length && i < meanDepthData.length; + i++ + ) { + spots.add( + FlSpot( + widget.profile[i].timestamp.toDouble(), + -units.convertDepth(meanDepthData[i]), + ), + ); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: meanDepthColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [3, 4], + ); + } + + /// Build TTS (Time To Surface) line + /// Shows total time including deco stops to reach surface + LineChartBarData _buildTtsLine(double chartMaxDepth) { + final ttsData = widget.ttsCurve!; + const ttsColor = Color( + 0xFFAD1457, + ); // Pink 800 - distinct from pressure orange + + // Map TTS to chart: 0 at top, 60 min at bottom + const maxTtsSeconds = 3600.0; + + final spots = []; + for (int i = 0; i < widget.profile.length && i < ttsData.length; i++) { + final tts = ttsData[i].toDouble().clamp(0, maxTtsSeconds); + final normalized = tts / maxTtsSeconds; + final yValue = chartMaxDepth * (1 - normalized); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: ttsColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [5, 4], + ); + } + + /// Compute dynamic max scale for CNS curve based on actual data. + double _getCnsMaxScale() { + if (widget.cnsCurve == null || widget.cnsCurve!.isEmpty) return 100.0; + final actualMax = widget.cnsCurve!.reduce(math.max); + return math.max(actualMax * 1.25, 10.0); // 25% headroom, min 10% + } + + /// Compute dynamic max scale for OTU curve based on actual data. + double _getOtuMaxScale() { + if (widget.otuCurve == null || widget.otuCurve!.isEmpty) return 100.0; + final actualMax = widget.otuCurve!.reduce(math.max); + return math.max(actualMax * 1.25, 20.0); // 25% headroom, min 20 OTU + } + + /// Build cumulative CNS% line + LineChartBarData _buildCnsLine(double chartMaxDepth) { + final cnsData = widget.cnsCurve!; + const cnsColor = Color(0xFFE65100); // Orange 900 + + const minCns = 0.0; + final maxCns = _getCnsMaxScale(); + + final spots = []; + for (int i = 0; i < widget.profile.length && i < cnsData.length; i++) { + final cns = cnsData[i].clamp(minCns, maxCns); + final yValue = _mapValueToDepth(cns, chartMaxDepth, minCns, maxCns); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: cnsColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [6, 3], + ); + } + + /// Build cumulative OTU line + LineChartBarData _buildOtuLine(double chartMaxDepth) { + final otuData = widget.otuCurve!; + const otuColor = Color(0xFF6D4C41); // Brown 600 + + const minOtu = 0.0; + final maxOtu = _getOtuMaxScale(); + + final spots = []; + for (int i = 0; i < widget.profile.length && i < otuData.length; i++) { + final otu = otuData[i].clamp(minOtu, maxOtu); + final yValue = _mapValueToDepth(otu, chartMaxDepth, minOtu, maxOtu); + spots.add(FlSpot(widget.profile[i].timestamp.toDouble(), -yValue)); + } + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: otuColor, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + dashArray: [4, 4], + ); + } + + /// Build vertical line for playback cursor + List _buildPlaybackCursor(ColorScheme colorScheme) { + final timestamp = widget.playbackTimestamp; + if (timestamp == null) { + return []; + } + + // Convert timestamp to x position (seconds) + final xPosition = timestamp.toDouble(); + + return [ + VerticalLine( + x: xPosition, + color: colorScheme.primary, + strokeWidth: 2, + dashArray: [4, 4], + label: VerticalLineLabel( + show: true, + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(bottom: 4), + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontSize: 10, + fontWeight: FontWeight.bold, + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.9, + ), + ), + labelResolver: (line) { + final minutes = timestamp ~/ 60; + final seconds = timestamp % 60; + return ' ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')} '; + }, + ), + ), + ]; + } + + /// Build vertical line for external highlight (e.g. heat map hover) + List _buildHighlightCursor(ColorScheme colorScheme) { + final timestamp = widget.highlightedTimestamp; + if (timestamp == null) { + return []; + } + + return [ + VerticalLine( + x: timestamp.toDouble(), + color: colorScheme.onSurface.withValues(alpha: 0.5), + strokeWidth: 1, + dashArray: [3, 3], + ), + ]; + } + + /// Build vertical lines for event markers on the dive profile. + /// + /// Groups events by timestamp and shows only the most severe event at each + /// timestamp to avoid overlapping labels. Lines are colored by severity: + /// info = primary, warning = orange, alert = red. + List _buildEventVerticalLines(ColorScheme colorScheme) { + final events = widget.events; + if (events == null || events.isEmpty) return []; + + // Group events by timestamp, keeping only the most severe at each time + final byTimestamp = {}; + for (final event in events) { + final existing = byTimestamp[event.timestamp]; + if (existing == null || event.severity.index > existing.severity.index) { + byTimestamp[event.timestamp] = event; + } + } + + return byTimestamp.values.map((event) { + final color = _eventSeverityColor(event.severity, colorScheme); + return VerticalLine( + x: event.timestamp.toDouble(), + color: color, + strokeWidth: 1, + dashArray: [3, 3], + label: VerticalLineLabel( + show: true, + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(bottom: 2), + style: TextStyle( + color: color, + fontSize: 9, + backgroundColor: colorScheme.surface.withValues(alpha: 0.8), + ), + labelResolver: (line) => event.displayName, + ), + ); + }).toList(); + } + + /// Returns the color for an event based on its severity level. + Color _eventSeverityColor(EventSeverity severity, ColorScheme colorScheme) { + switch (severity) { + case EventSeverity.info: + return colorScheme.primary.withValues(alpha: 0.5); + case EventSeverity.warning: + return Colors.orange; + case EventSeverity.alert: + return Colors.red; + } + } + + /// Build marker lines for max depth and pressure thresholds + List _buildMarkerLines( + UnitFormatter units, + double chartMaxDepth, { + double? minPressure, + double? maxPressure, + }) { + final lines = []; + final markers = widget.markers; + + if (markers == null || markers.isEmpty) return lines; + + for (final marker in markers) { + // Skip max depth markers if setting is off or locally toggled off + if (marker.type == ProfileMarkerType.maxDepth) { + if (!widget.showMaxDepthMarker || !_showMaxDepthMarkerLocal) continue; + } else { + // Skip pressure markers if setting is off or locally toggled off + if (!widget.showPressureThresholdMarkers || + !_showPressureMarkersLocal) { + continue; + } + } + + lines.add( + _buildSingleMarkerLine( + marker, + units, + chartMaxDepth, + minPressure: minPressure, + maxPressure: maxPressure, + ), + ); + } + + return lines; + } + + /// Build a single marker as a LineChartBarData with a visible dot + LineChartBarData _buildSingleMarkerLine( + ProfileMarker marker, + UnitFormatter units, + double chartMaxDepth, { + double? minPressure, + double? maxPressure, + }) { + final color = marker.getColor(); + final size = marker.markerSize; + + // Calculate Y position based on marker type + double yPosition; + if (marker.type == ProfileMarkerType.maxDepth) { + // Max depth marker: position on depth line + yPosition = -units.convertDepth(marker.depth); + } else { + // Pressure threshold marker: position on pressure line + // Use the threshold pressure value (marker.value) mapped to the chart's Y axis + if (minPressure != null && maxPressure != null && marker.value != null) { + yPosition = -_mapValueToDepth( + marker.value!, + chartMaxDepth, + minPressure, + maxPressure, + ); + } else { + // Fallback to depth position if pressure range not available + yPosition = -units.convertDepth(marker.depth); + } + } + + return LineChartBarData( + spots: [FlSpot(marker.timestamp.toDouble(), yPosition)], + isCurved: false, + color: Colors.transparent, + barWidth: 0, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, bar, index) { + if (marker.type == ProfileMarkerType.maxDepth) { + // Max depth: red circle with white border + return FlDotCirclePainter( + radius: size, + color: color, + strokeWidth: 2, + strokeColor: Colors.white, + ); + } else { + // Pressure threshold: colored circle with darker border + return FlDotCirclePainter( + radius: size, + color: color.withValues(alpha: 0.9), + strokeWidth: 1.5, + strokeColor: color.withValues(alpha: 0.5), + ); + } + }, + ), + ); + } + + /// Check if a specific metric has data available in this dive profile + bool _hasDataForMetric(ProfileRightAxisMetric metric) { + switch (metric) { + case ProfileRightAxisMetric.temperature: + return widget.profile.any((p) => p.temperature != null); + case ProfileRightAxisMetric.pressure: + return _hasMultiTankPressure; + case ProfileRightAxisMetric.heartRate: + return widget.profile.any((p) => p.heartRate != null); + case ProfileRightAxisMetric.sac: + return widget.sacCurve != null && widget.sacCurve!.any((s) => s > 0); + case ProfileRightAxisMetric.ndl: + return widget.ndlCurve != null && widget.ndlCurve!.isNotEmpty; + case ProfileRightAxisMetric.ppO2: + return widget.ppO2Curve != null && widget.ppO2Curve!.isNotEmpty; + case ProfileRightAxisMetric.ppN2: + return widget.ppN2Curve != null && widget.ppN2Curve!.isNotEmpty; + case ProfileRightAxisMetric.ppHe: + return widget.ppHeCurve != null && + widget.ppHeCurve!.any((v) => v > 0.001); + case ProfileRightAxisMetric.gasDensity: + return widget.densityCurve != null && widget.densityCurve!.isNotEmpty; + case ProfileRightAxisMetric.gf: + return widget.gfCurve != null && widget.gfCurve!.isNotEmpty; + case ProfileRightAxisMetric.surfaceGf: + return widget.surfaceGfCurve != null && + widget.surfaceGfCurve!.isNotEmpty; + case ProfileRightAxisMetric.meanDepth: + return widget.meanDepthCurve != null && + widget.meanDepthCurve!.isNotEmpty; + case ProfileRightAxisMetric.tts: + return widget.ttsCurve != null && widget.ttsCurve!.isNotEmpty; + case ProfileRightAxisMetric.cns: + return widget.cnsCurve != null && widget.cnsCurve!.isNotEmpty; + case ProfileRightAxisMetric.otu: + return widget.otuCurve != null && widget.otuCurve!.isNotEmpty; + } + } + + /// Get the effective right axis metric using the fallback chain + ProfileRightAxisMetric? _getEffectiveRightAxisMetric( + ProfileRightAxisMetric preferred, + ) { + // First, check if the preferred metric has data + if (_hasDataForMetric(preferred)) { + return preferred; + } + + // Fall back through the priority chain + for (final fallback in ProfileRightAxisMetric.fallbackPriority) { + if (_hasDataForMetric(fallback)) { + return fallback; + } + } + + // No metric has data + return null; + } + + /// Get the min/max value range for a metric + ({double min, double max})? _getMetricRange( + ProfileRightAxisMetric metric, + UnitFormatter units, + ) { + switch (metric) { + case ProfileRightAxisMetric.temperature: + final temps = widget.profile + .where((p) => p.temperature != null) + .map((p) => units.convertTemperature(p.temperature!)); + if (temps.isEmpty) return null; + return ( + min: temps.reduce(math.min) - 1, + max: temps.reduce(math.max) + 1, + ); + + case ProfileRightAxisMetric.pressure: + if (!_hasMultiTankPressure || widget.tankPressures == null) return null; + double? pMin, pMax; + for (final points in widget.tankPressures!.values) { + for (final pt in points) { + if (pMin == null || pt.pressure < pMin) pMin = pt.pressure; + if (pMax == null || pt.pressure > pMax) pMax = pt.pressure; + } + } + if (pMin == null || pMax == null) return null; + return (min: pMin - 10, max: pMax + 10); + + case ProfileRightAxisMetric.heartRate: + final hrs = widget.profile + .where((p) => p.heartRate != null) + .map((p) => p.heartRate!.toDouble()); + if (hrs.isEmpty) return null; + return (min: hrs.reduce(math.min) - 5, max: hrs.reduce(math.max) + 5); + + case ProfileRightAxisMetric.sac: + if (widget.sacCurve == null) return null; + final sacs = widget.sacCurve!.where((s) => s > 0); + if (sacs.isEmpty) return null; + return (min: 0.0, max: sacs.reduce(math.max) * 1.2); + + case ProfileRightAxisMetric.ndl: + return (min: 0.0, max: 3600.0); // 0-60 minutes + + case ProfileRightAxisMetric.ppO2: + return (min: 0.0, max: 2.0); // 0-2.0 bar + + case ProfileRightAxisMetric.ppN2: + return (min: 0.0, max: 5.0); // 0-5.0 bar + + case ProfileRightAxisMetric.ppHe: + return (min: 0.0, max: 3.0); // 0-3.0 bar + + case ProfileRightAxisMetric.gasDensity: + return (min: 0.0, max: 8.0); // 0-8 g/L + + case ProfileRightAxisMetric.gf: + return (min: 0.0, max: 120.0); // 0-120% + + case ProfileRightAxisMetric.surfaceGf: + return (min: 0.0, max: 150.0); // 0-150% + + case ProfileRightAxisMetric.meanDepth: + if (widget.meanDepthCurve == null) return null; + final depths = widget.meanDepthCurve!; + if (depths.isEmpty) return null; + return (min: 0.0, max: depths.reduce(math.max) * 1.1); + + case ProfileRightAxisMetric.tts: + return (min: 0.0, max: 3600.0); // 0-60 minutes + + case ProfileRightAxisMetric.cns: + if (widget.cnsCurve == null || widget.cnsCurve!.isEmpty) return null; + return (min: 0.0, max: _getCnsMaxScale()); + + case ProfileRightAxisMetric.otu: + if (widget.otuCurve == null || widget.otuCurve!.isEmpty) return null; + return (min: 0.0, max: _getOtuMaxScale()); + } + } + + /// Format right axis tick values as plain numbers (units shown in axis label). + /// + /// Values from [_getMetricRange] are in storage units (bar, meters, etc.). + /// Temperature is pre-converted in [_getMetricRange]; all others are + /// converted here at display time to match the user's unit preferences. + String _formatRightAxisValue( + ProfileRightAxisMetric metric, + double value, + UnitFormatter units, + ) { + switch (metric) { + // Temperature range is already in user units (converted in _getMetricRange) + case ProfileRightAxisMetric.temperature: + return value.toStringAsFixed(0); + // Pressure stored in bar -> convert to user unit + case ProfileRightAxisMetric.pressure: + return units.convertPressure(value).toStringAsFixed(0); + // SAC stored in bar/min -> convert pressure component to user unit + case ProfileRightAxisMetric.sac: + return units.convertPressure(value).toStringAsFixed(1); + // Mean depth stored in meters -> convert to user unit + case ProfileRightAxisMetric.meanDepth: + return units.convertDepth(value).toStringAsFixed(0); + // Universal units - no conversion needed + case ProfileRightAxisMetric.heartRate: + case ProfileRightAxisMetric.gf: + case ProfileRightAxisMetric.surfaceGf: + return value.toStringAsFixed(0); + case ProfileRightAxisMetric.ppO2: + case ProfileRightAxisMetric.ppN2: + case ProfileRightAxisMetric.ppHe: + case ProfileRightAxisMetric.gasDensity: + return value.toStringAsFixed(1); + case ProfileRightAxisMetric.ndl: + case ProfileRightAxisMetric.tts: + return (value / 60).round().toString(); + case ProfileRightAxisMetric.cns: + case ProfileRightAxisMetric.otu: + return value.toStringAsFixed(0); + } + } + + /// Build axis label text for the right axis (e.g. "Temp (°C)"). + String _rightAxisLabel(ProfileRightAxisMetric metric, UnitFormatter units) { + final name = metric.shortName; + switch (metric) { + case ProfileRightAxisMetric.temperature: + return '$name (${units.temperatureSymbol})'; + case ProfileRightAxisMetric.pressure: + return '$name (${units.pressureSymbol})'; + case ProfileRightAxisMetric.meanDepth: + return '$name (${units.depthSymbol})'; + case ProfileRightAxisMetric.sac: + return '$name (${units.pressureSymbol}/min)'; + default: + final suffix = metric.unitSuffix; + if (suffix != null) return '$name ($suffix)'; + return name; + } + } + + /// Map a depth axis value back to the metric value for axis labels + double _mapDepthToMetricValue( + double depthAxisValue, + double maxDepth, + double minValue, + double maxValue, + ) { + final normalized = 1 - (depthAxisValue / maxDepth); + return minValue + (normalized * (maxValue - minValue)); + } +} + +/// Compact version of the dive profile chart for list previews +class DiveProfileMiniChart extends StatelessWidget { + final List profile; + final double height; + final Color? color; + + const DiveProfileMiniChart({ + super.key, + required this.profile, + this.height = 40, + this.color, + }); + + @override + Widget build(BuildContext context) { + if (profile.isEmpty) { + return SizedBox(height: height); + } + + final chartColor = color ?? Theme.of(context).colorScheme.primary; + final maxDepth = profile.map((p) => p.depth).reduce(math.max) * 1.1; + final maxTime = profile.map((p) => p.timestamp).reduce(math.max).toDouble(); + + return SizedBox( + height: height, + child: LineChart( + LineChartData( + minX: 0, + maxX: maxTime, + minY: -maxDepth, // Inverted: negative depth at bottom + maxY: 0, // Surface (0m) at top + gridData: const FlGridData(show: false), + titlesData: const FlTitlesData(show: false), + borderData: FlBorderData(show: false), + lineTouchData: const LineTouchData(enabled: false), + lineBarsData: [ + LineChartBarData( + spots: profile + .map( + (p) => FlSpot(p.timestamp.toDouble(), -p.depth), + ) // Negate for inverted axis + .toList(), + // Straight segments preserve the actual sample-to-sample shape + // (safety stops, multilevel ledges, abrupt descents). Catmull- + // Rom smoothing flattens those short features into rounded + // arcs, producing a less informative "blob" silhouette. + isCurved: false, + color: chartColor, + barWidth: 1.5, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: chartColor.withValues(alpha: 0.2), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dive_log/presentation/widgets/dive_profile_legend.dart b/lib/features/dive_log/presentation/widgets/dive_profile_legend.dart index 84477527b..9ab607854 100644 --- a/lib/features/dive_log/presentation/widgets/dive_profile_legend.dart +++ b/lib/features/dive_log/presentation/widgets/dive_profile_legend.dart @@ -22,6 +22,7 @@ class ProfileLegendConfig { final bool hasPressureMarkers; final bool hasGasSwitches; final bool hasMultiTankPressure; + final bool hasGasData; final List? tanks; final Map>? tankPressures; @@ -50,6 +51,7 @@ class ProfileLegendConfig { this.hasPressureMarkers = false, this.hasGasSwitches = false, this.hasMultiTankPressure = false, + this.hasGasData = false, this.tanks, this.tankPressures, this.hasNdlData = false, @@ -79,6 +81,7 @@ class ProfileLegendConfig { hasPressureMarkers || hasGasSwitches || hasTankListSection || + hasGasData || hasMultiTankPressure || hasNdlData || hasPpO2Data || @@ -303,6 +306,7 @@ class _MoreOptionsButton extends ConsumerWidget { if (config.hasTtsData && legendState.showTts) count++; if (config.hasCnsData && legendState.showCns) count++; if (config.hasOtuData && legendState.showOtu) count++; + if (config.hasGasData && legendState.showGas) count++; // Count active tank pressure toggles if (config.hasMultiTankPressure && config.tankPressures != null) { @@ -463,6 +467,13 @@ class _ChartOptionsDialog extends StatelessWidget { isEnabled: legendState.showAscentRateColors, onTap: legendNotifier.toggleAscentRateColors, ), + if (config.hasGasData) + _buildGasToggleItem( + context, + label: context.l10n.diveLog_legend_label_showGas, + isEnabled: legendState.showGas, + onTap: legendNotifier.toggleGas, + ), ]; if (overlayItems.isNotEmpty) { sections.add( @@ -850,6 +861,61 @@ class _ChartOptionsDialog extends StatelessWidget { ); } + /// Variant of [_buildToggleItem] for the gas-timeline visibility toggle. + /// Replaces the single-color decoration stripe with four stacked bars in + /// the air → nitrox → oxygen → trimix colors so the indicator visually + /// advertises every gas type the strip can render, not just one. + Widget _buildGasToggleItem( + BuildContext context, { + required String label, + required bool isEnabled, + required VoidCallback onTap, + }) { + final colorScheme = Theme.of(context).colorScheme; + final iconColor = isEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant; + Widget bar(Color color) => Container( + width: 16, + height: 3, + decoration: BoxDecoration( + color: isEnabled ? color : color.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon( + isEnabled ? Icons.check_box : Icons.check_box_outline_blank, + size: 20, + color: iconColor, + ), + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + bar(GasColors.air), + const SizedBox(height: 1), + bar(GasColors.nitrox), + const SizedBox(height: 1), + bar(GasColors.oxygen), + const SizedBox(height: 1), + bar(GasColors.trimix), + ], + ), + const SizedBox(width: 8), + Expanded(child: Text(label)), + ], + ), + ), + ); + } + Widget _buildToggleItem( BuildContext context, { required String label, diff --git a/lib/features/dive_log/presentation/widgets/dive_profile_panel.dart b/lib/features/dive_log/presentation/widgets/dive_profile_panel.dart index 49da5258a..1ff3ee2e0 100644 --- a/lib/features/dive_log/presentation/widgets/dive_profile_panel.dart +++ b/lib/features/dive_log/presentation/widgets/dive_profile_panel.dart @@ -9,6 +9,7 @@ import 'package:submersion/features/dive_log/presentation/providers/gas_switch_p import 'package:submersion/features/dive_log/presentation/providers/highlight_providers.dart'; import 'package:submersion/features/dive_log/presentation/providers/profile_analysis_provider.dart'; import 'package:submersion/features/dive_log/presentation/providers/profile_tracking_provider.dart'; +import 'package:submersion/features/dive_log/data/services/gas_usage_segments_service.dart'; import 'package:submersion/features/dive_log/data/services/profile_markers_service.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_chart.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -386,6 +387,16 @@ class _DiveProfilePanelContentState tanks: dive.tanks, tankPressures: tankPressures, gasSwitches: gasSwitches, + gasSegments: (dive.tanks.isEmpty || dive.profile.isEmpty) + ? null + : buildGasUsageSegments( + tanks: dive.tanks, + gasSwitches: gasSwitches ?? const [], + diveDurationSeconds: dive.profile.last.timestamp, + ), + diveDurationSeconds: dive.profile.isEmpty + ? null + : dive.profile.last.timestamp, tooltipBelow: true, highlightedTimestamp: trackingIndex != null && diff --git a/lib/features/dive_log/presentation/widgets/gas_colors.dart b/lib/features/dive_log/presentation/widgets/gas_colors.dart index 59b36b5be..a62083241 100644 --- a/lib/features/dive_log/presentation/widgets/gas_colors.dart +++ b/lib/features/dive_log/presentation/widgets/gas_colors.dart @@ -9,6 +9,7 @@ import 'package:submersion/features/dive_log/presentation/providers/gas_switch_p /// line ([AppColors.chartDepth]) and match common diving conventions: /// - Air: Orange (contrasts with depth line, standard pressure color) /// - Nitrox: Green (enriched air = "greener" for your body) +/// - Oxygen: Blue (pure O2 deco gas) /// - Trimix: Purple (technical diving = more exotic) class GasColors { GasColors._(); // Prevent instantiation @@ -19,6 +20,9 @@ class GasColors { /// Color for Nitrox (>21% O2, no He) static const Color nitrox = Color(0xFF4CAF50); // Green + /// Color for pure Oxygen (>=99% O2, no He) - deco gas + static const Color oxygen = Color(0xFF1976D2); // Blue (blue.shade700) + /// Color for Trimix (contains He) static const Color trimix = Color(0xFF9C27B0); // Purple @@ -29,6 +33,8 @@ class GasColors { return air; case GasType.nitrox: return nitrox; + case GasType.oxygen: + return oxygen; case GasType.trimix: return trimix; } @@ -37,6 +43,7 @@ class GasColors { /// Get color for a GasMix object static Color forGasMix(GasMix gasMix) { if (gasMix.isTrimix) return trimix; + if (gasMix.isOxygen) return oxygen; if (gasMix.isNitrox) return nitrox; return air; } @@ -44,6 +51,7 @@ class GasColors { /// Get color for O2 and He percentages (0-100 scale) static Color forMixPercent(double o2Percent, double hePercent) { if (hePercent > 0) return trimix; + if (o2Percent >= 99) return oxygen; if (o2Percent > 22) return nitrox; return air; } @@ -51,6 +59,7 @@ class GasColors { /// Get color for O2 and He fractions (0-1 scale) static Color forMixFraction(double o2Fraction, double heFraction) { if (heFraction > 0) return trimix; + if (o2Fraction >= 0.99) return oxygen; if (o2Fraction > 0.22) return nitrox; return air; } diff --git a/lib/features/dive_log/presentation/widgets/gas_timeline_strip.dart b/lib/features/dive_log/presentation/widgets/gas_timeline_strip.dart new file mode 100644 index 000000000..b33bed094 --- /dev/null +++ b/lib/features/dive_log/presentation/widgets/gas_timeline_strip.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/features/dive_log/data/services/gas_usage_segments_service.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_chart.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/gas_colors.dart'; + +/// Horizontal strip rendered between the dive profile's plot area and its +/// X-axis tick labels, showing which gas was breathed at every point of +/// the dive. +/// +/// Used in two modes: +/// - **Embedded** (typical): mounted by [DiveProfileChart] inside a +/// `Positioned` widget that already constrains it to the plot width; +/// pass `leftPadding: 0` and `rightPadding: 0`. +/// - **Standalone**: with default null paddings the strip mirrors +/// [DiveProfileChart.leftAxisSize] / [DiveProfileChart.rightAxisSize] +/// for the available width — the same reservations the chart uses for +/// its y-axis side titles — so blocks line up with the chart's time axis. +/// +/// Each [GasUsageSegment] is drawn as a colored block proportional to its +/// duration, with the gas name shown inside when there is enough room. +class GasTimelineStrip extends StatelessWidget { + final List segments; + final int diveDurationSeconds; + + /// Strip height. Matches Subsurface's gas bar (slim — single label line). + final double height; + + /// Horizontal insets to align with the chart's plot area. When null (the + /// default), the strip mirrors [DiveProfileChart.leftAxisSize] / + /// [DiveProfileChart.rightAxisSize] for the available width — the same + /// reservations the chart uses for its y-axis side titles — so the strip + /// blocks line up exactly with the chart's time axis. + final double? leftPadding; + final double? rightPadding; + + /// When provided, the strip maps segments onto this visible time window + /// rather than the full dive. Used to keep the strip in sync with the + /// chart's zoom/pan state. + final double? visibleMinSeconds; + final double? visibleMaxSeconds; + + const GasTimelineStrip({ + super.key, + required this.segments, + required this.diveDurationSeconds, + this.height = 22, + this.leftPadding, + this.rightPadding, + this.visibleMinSeconds, + this.visibleMaxSeconds, + }); + + @override + Widget build(BuildContext context) { + if (segments.isEmpty || diveDurationSeconds <= 0) { + return const SizedBox.shrink(); + } + final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final left = + leftPadding ?? DiveProfileChart.leftAxisSize(availableWidth); + final right = + rightPadding ?? DiveProfileChart.rightAxisSize(availableWidth); + final usableWidth = (availableWidth - left - right).clamp( + 0.0, + double.infinity, + ); + + return SizedBox( + height: height, + child: Padding( + padding: EdgeInsets.only(left: left, right: right), + child: ClipRRect( + child: SizedBox( + width: usableWidth, + height: height, + child: Stack( + children: [ + for (final segment in segments) + _buildSegment(segment, usableWidth, labelStyle), + ], + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildSegment( + GasUsageSegment segment, + double usableWidth, + TextStyle? labelStyle, + ) { + final viewMin = visibleMinSeconds ?? 0.0; + final viewMax = visibleMaxSeconds ?? diveDurationSeconds.toDouble(); + final viewRange = viewMax - viewMin; + if (viewRange <= 0) return const SizedBox.shrink(); + final startFraction = ((segment.startSeconds - viewMin) / viewRange).clamp( + 0.0, + 1.0, + ); + final endFraction = ((segment.endSeconds - viewMin) / viewRange).clamp( + 0.0, + 1.0, + ); + final blockLeft = startFraction * usableWidth; + final blockWidth = ((endFraction - startFraction) * usableWidth).clamp( + 0.0, + usableWidth, + ); + if (blockWidth <= 0) return const SizedBox.shrink(); + final color = GasColors.forGasMix(segment.gasMix); + + return Positioned( + left: blockLeft, + top: 0, + width: blockWidth, + height: height, + child: Tooltip( + message: segment.label, + waitDuration: const Duration(milliseconds: 400), + child: Container( + color: color, + alignment: Alignment.center, + child: blockWidth > 36 + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + segment.label, + style: labelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + : null, + ), + ), + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 8e0a2a580..9c4779954 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1349,6 +1349,7 @@ "diveLog_legend_label_pressure": "الضغط", "diveLog_legend_label_pressureThresholds": "عتبات الضغط", "diveLog_legend_label_sacRate": "معدل SAC", + "diveLog_legend_label_showGas": "الغازات", "diveLog_legend_label_surfaceGf": "GF السطح", "diveLog_legend_label_temp": "الحرارة", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 2d39ad729..ab5efa453 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1349,6 +1349,7 @@ "diveLog_legend_label_pressure": "Druck", "diveLog_legend_label_pressureThresholds": "Druckschwellen", "diveLog_legend_label_sacRate": "SAC-Rate", + "diveLog_legend_label_showGas": "Gase", "diveLog_legend_label_surfaceGf": "Oberflächenm GF", "diveLog_legend_label_temp": "Temp.", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 614101e63..d5210a5c6 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1997,6 +1997,7 @@ "diveLog_legend_label_pressure": "Pressure", "diveLog_legend_label_pressureThresholds": "Pressure Thresholds", "diveLog_legend_label_sacRate": "SAC Rate", + "diveLog_legend_label_showGas": "Gases", "diveLog_legend_label_surfaceGf": "Surface GF", "diveLog_legend_label_temp": "Temp", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 34d1001af..3d2eac1a0 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1349,6 +1349,7 @@ "diveLog_legend_label_pressure": "Presión", "diveLog_legend_label_pressureThresholds": "Umbrales de presión", "diveLog_legend_label_sacRate": "Consumo SAC", + "diveLog_legend_label_showGas": "Gases", "diveLog_legend_label_surfaceGf": "GF en superficie", "diveLog_legend_label_temp": "Temp", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 879ca87a8..e6d419a53 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1315,6 +1315,7 @@ "diveLog_legend_label_pressure": "Pression", "diveLog_legend_label_pressureThresholds": "Seuils de pression", "diveLog_legend_label_sacRate": "Consommation SAC", + "diveLog_legend_label_showGas": "Gaz", "diveLog_legend_label_surfaceGf": "GF surface", "diveLog_legend_label_temp": "Temp", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index e45718250..2ecfd3302 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1315,6 +1315,7 @@ "diveLog_legend_label_pressure": "לחץ", "diveLog_legend_label_pressureThresholds": "ספי לחץ", "diveLog_legend_label_sacRate": "קצב SAC", + "diveLog_legend_label_showGas": "גזים", "diveLog_legend_label_surfaceGf": "GF פני השטח", "diveLog_legend_label_temp": "טמפ'", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index c3ee033cb..86f586820 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1315,6 +1315,7 @@ "diveLog_legend_label_pressure": "Nyomas", "diveLog_legend_label_pressureThresholds": "Nyomas kuszobertek", "diveLog_legend_label_sacRate": "SAC ertek", + "diveLog_legend_label_showGas": "Gazok", "diveLog_legend_label_surfaceGf": "Felszini GF", "diveLog_legend_label_temp": "Hom.", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 6e555e7d9..98b3d33d3 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1315,6 +1315,7 @@ "diveLog_legend_label_pressure": "Pressione", "diveLog_legend_label_pressureThresholds": "Soglie di pressione", "diveLog_legend_label_sacRate": "Consumo SAC", + "diveLog_legend_label_showGas": "Gas", "diveLog_legend_label_surfaceGf": "GF superficie", "diveLog_legend_label_temp": "Temp", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 906f6d0fb..02dba15b3 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -6796,6 +6796,12 @@ abstract class AppLocalizations { /// **'SAC Rate'** String get diveLog_legend_label_sacRate; + /// No description provided for @diveLog_legend_label_showGas. + /// + /// In en, this message translates to: + /// **'Gases'** + String get diveLog_legend_label_showGas; + /// No description provided for @diveLog_legend_label_surfaceGf. /// /// 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 0cd43b677..0d8281ca3 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -3901,6 +3901,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'معدل SAC'; + @override + String get diveLog_legend_label_showGas => 'الغازات'; + @override String get diveLog_legend_label_surfaceGf => 'GF السطح'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 5d68a9787..d8b8d99f6 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -3999,6 +3999,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'SAC-Rate'; + @override + String get diveLog_legend_label_showGas => 'Gase'; + @override String get diveLog_legend_label_surfaceGf => 'Oberflächenm GF'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index d342014bf..0aeab00f7 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -3927,6 +3927,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'SAC Rate'; + @override + String get diveLog_legend_label_showGas => 'Gases'; + @override String get diveLog_legend_label_surfaceGf => 'Surface GF'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index b82c933c5..414038bc8 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -3998,6 +3998,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'Consumo SAC'; + @override + String get diveLog_legend_label_showGas => 'Gases'; + @override String get diveLog_legend_label_surfaceGf => 'GF en superficie'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index ef45d61c7..c065090b4 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -4022,6 +4022,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'Consommation SAC'; + @override + String get diveLog_legend_label_showGas => 'Gaz'; + @override String get diveLog_legend_label_surfaceGf => 'GF surface'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 1df428776..f25d2f7ea 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -3886,6 +3886,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'קצב SAC'; + @override + String get diveLog_legend_label_showGas => 'גזים'; + @override String get diveLog_legend_label_surfaceGf => 'GF פני השטח'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 8285dc4d3..581d585e8 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -3986,6 +3986,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'SAC ertek'; + @override + String get diveLog_legend_label_showGas => 'Gazok'; + @override String get diveLog_legend_label_surfaceGf => 'Felszini GF'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index b75bd7f85..d618cca14 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -4005,6 +4005,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'Consumo SAC'; + @override + String get diveLog_legend_label_showGas => 'Gas'; + @override String get diveLog_legend_label_surfaceGf => 'GF superficie'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c5c043063..d9cc2b306 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -3970,6 +3970,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'SAC-verbruik'; + @override + String get diveLog_legend_label_showGas => 'Gassen'; + @override String get diveLog_legend_label_surfaceGf => 'Oppervlakte GF'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 7d775e7c1..79281178e 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -4000,6 +4000,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveLog_legend_label_sacRate => 'Taxa SAC'; + @override + String get diveLog_legend_label_showGas => 'Gases'; + @override String get diveLog_legend_label_surfaceGf => 'GF de Superficie'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 0b4f94500..583c36a45 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -3805,6 +3805,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveLog_legend_label_sacRate => '气体消耗率'; + @override + String get diveLog_legend_label_showGas => '气体'; + @override String get diveLog_legend_label_surfaceGf => '水面 GF'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 91620d785..8e1427d1e 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1349,6 +1349,7 @@ "diveLog_legend_label_pressure": "Druk", "diveLog_legend_label_pressureThresholds": "Drukdrempels", "diveLog_legend_label_sacRate": "SAC-verbruik", + "diveLog_legend_label_showGas": "Gassen", "diveLog_legend_label_surfaceGf": "Oppervlakte GF", "diveLog_legend_label_temp": "Temp", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 8cdc859e9..5a87ad8c4 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1349,6 +1349,7 @@ "diveLog_legend_label_pressure": "Pressao", "diveLog_legend_label_pressureThresholds": "Limiares de Pressao", "diveLog_legend_label_sacRate": "Taxa SAC", + "diveLog_legend_label_showGas": "Gases", "diveLog_legend_label_surfaceGf": "GF de Superficie", "diveLog_legend_label_temp": "Temp", "diveLog_legend_label_tts": "TTS", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index e0a9cdc65..2dc5c5cb6 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1476,6 +1476,7 @@ "diveLog_legend_label_pressure": "压力", "diveLog_legend_label_pressureThresholds": "压力阈值", "diveLog_legend_label_sacRate": "气体消耗率", + "diveLog_legend_label_showGas": "气体", "diveLog_legend_label_surfaceGf": "水面 GF", "diveLog_legend_label_temp": "温度", "diveLog_legend_label_tts": "TTS", diff --git a/test/features/dive_log/data/services/gas_usage_segments_service_test.dart b/test/features/dive_log/data/services/gas_usage_segments_service_test.dart new file mode 100644 index 000000000..f8924dc81 --- /dev/null +++ b/test/features/dive_log/data/services/gas_usage_segments_service_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/data/services/gas_usage_segments_service.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; + +DiveTank _tank({ + required String id, + required double o2, + double he = 0, + String? name, + int order = 0, +}) { + return DiveTank( + id: id, + name: name, + gasMix: GasMix(o2: o2, he: he), + order: order, + ); +} + +GasSwitchWithTank _switch({ + required String tankId, + required int timestamp, + required double o2Fraction, + double heFraction = 0, + String tankName = '', + String gasMix = '', +}) { + return GasSwitchWithTank( + gasSwitch: GasSwitch( + id: 'gs-$timestamp', + diveId: 'dive-1', + timestamp: timestamp, + tankId: tankId, + createdAt: DateTime(2026, 1, 1), + ), + tankName: tankName, + gasMix: gasMix, + o2Fraction: o2Fraction, + heFraction: heFraction, + ); +} + +void main() { + group('buildGasUsageSegments', () { + test('returns empty list when there are no tanks', () { + final segments = buildGasUsageSegments( + tanks: const [], + gasSwitches: const [], + diveDurationSeconds: 1800, + ); + expect(segments, isEmpty); + }); + + test('returns empty list when dive duration is zero', () { + final segments = buildGasUsageSegments( + tanks: [_tank(id: 't1', o2: 21)], + gasSwitches: const [], + diveDurationSeconds: 0, + ); + expect(segments, isEmpty); + }); + + test( + 'single tank with no switches yields one segment covering the dive', + () { + final tank = _tank(id: 't1', o2: 21, name: 'Primary'); + final segments = buildGasUsageSegments( + tanks: [tank], + gasSwitches: const [], + diveDurationSeconds: 3000, + ); + expect(segments, hasLength(1)); + expect(segments.single.startSeconds, 0); + expect(segments.single.endSeconds, 3000); + expect(segments.single.gasMix.o2, 21); + expect(segments.single.tankName, 'Primary'); + expect(segments.single.label, 'Air'); + }, + ); + + test('first switch at t=0 emits one segment per switch boundary', () { + final back = _tank(id: 'back', o2: 21); + final deco = _tank(id: 'deco', o2: 50, order: 1); + final segments = buildGasUsageSegments( + tanks: [back, deco], + gasSwitches: [ + _switch(tankId: 'back', timestamp: 0, o2Fraction: 0.21), + _switch(tankId: 'deco', timestamp: 1500, o2Fraction: 0.50), + ], + diveDurationSeconds: 3000, + ); + expect(segments, hasLength(2)); + expect(segments[0].startSeconds, 0); + expect(segments[0].endSeconds, 1500); + expect(segments[0].gasMix.o2, 21); + expect(segments[1].startSeconds, 1500); + expect(segments[1].endSeconds, 3000); + expect(segments[1].gasMix.o2, 50); + }); + + test('first switch after t=0 inserts a starting-tank segment from t=0', () { + final back = _tank(id: 'back', o2: 32, name: 'Back'); + final deco = _tank(id: 'deco', o2: 80, order: 1); + final segments = buildGasUsageSegments( + tanks: [back, deco], + gasSwitches: [ + _switch(tankId: 'deco', timestamp: 600, o2Fraction: 0.80), + ], + diveDurationSeconds: 1800, + ); + expect(segments, hasLength(2)); + expect(segments[0].startSeconds, 0); + expect(segments[0].endSeconds, 600); + expect(segments[0].gasMix.o2, 32); + expect(segments[0].tankName, 'Back'); + expect(segments[1].startSeconds, 600); + expect(segments[1].endSeconds, 1800); + expect(segments[1].gasMix.o2, 80); + }); + + test('starting tank is the lowest-order tank, not list position', () { + final later = _tank(id: 'late', o2: 100, name: 'Late', order: 2); + final first = _tank(id: 'first', o2: 21, name: 'First', order: 0); + final segments = buildGasUsageSegments( + tanks: [later, first], + gasSwitches: const [], + diveDurationSeconds: 1200, + ); + expect(segments, hasLength(1)); + expect(segments.single.tankName, 'First'); + expect(segments.single.gasMix.o2, 21); + }); + + test('switch back to the same gas merges with the previous segment', () { + final back = _tank(id: 'back', o2: 21); + final deco = _tank(id: 'deco', o2: 21, order: 1); + final segments = buildGasUsageSegments( + tanks: [back, deco], + gasSwitches: [ + _switch(tankId: 'back', timestamp: 0, o2Fraction: 0.21), + _switch(tankId: 'deco', timestamp: 600, o2Fraction: 0.21), + _switch(tankId: 'back', timestamp: 1200, o2Fraction: 0.21), + ], + diveDurationSeconds: 1800, + ); + expect(segments, hasLength(1)); + expect(segments.single.startSeconds, 0); + expect(segments.single.endSeconds, 1800); + }); + + test('switches outside dive bounds are dropped', () { + final tank = _tank(id: 't1', o2: 21); + final tank2 = _tank(id: 't2', o2: 50, order: 1); + final segments = buildGasUsageSegments( + tanks: [tank, tank2], + gasSwitches: [ + _switch(tankId: 't2', timestamp: -10, o2Fraction: 0.50), + _switch(tankId: 't2', timestamp: 5000, o2Fraction: 0.50), + ], + diveDurationSeconds: 1800, + ); + expect(segments, hasLength(1)); + expect(segments.single.gasMix.o2, 21); + }); + + test('unsorted switches are normalised before segment construction', () { + final back = _tank(id: 'back', o2: 21); + final deco = _tank(id: 'deco', o2: 50, order: 1); + final segments = buildGasUsageSegments( + tanks: [back, deco], + gasSwitches: [ + _switch(tankId: 'deco', timestamp: 1500, o2Fraction: 0.50), + _switch(tankId: 'back', timestamp: 0, o2Fraction: 0.21), + ], + diveDurationSeconds: 3000, + ); + expect(segments, hasLength(2)); + expect(segments[0].endSeconds, 1500); + expect(segments[1].startSeconds, 1500); + }); + + test('trimix segment exposes both o2 and he percentages', () { + final back = _tank(id: 'back', o2: 21, he: 35, order: 0); + final segments = buildGasUsageSegments( + tanks: [back], + gasSwitches: [ + _switch( + tankId: 'back', + timestamp: 0, + o2Fraction: 0.18, + heFraction: 0.45, + ), + ], + diveDurationSeconds: 2400, + ); + expect(segments, hasLength(1)); + expect(segments.single.gasMix.o2, 18); + expect(segments.single.gasMix.he, 45); + expect(segments.single.label, 'Tx 18/45'); + }); + }); +} diff --git a/test/features/dive_log/domain/entities/gas_mix_test.dart b/test/features/dive_log/domain/entities/gas_mix_test.dart index 15ef7af4e..3b597254c 100644 --- a/test/features/dive_log/domain/entities/gas_mix_test.dart +++ b/test/features/dive_log/domain/entities/gas_mix_test.dart @@ -12,6 +12,53 @@ void main() { const tx2135 = GasMix(o2: 20.999999999, he: 34.999999999); expect(tx2135.name, 'Tx 21/35'); }); + + test('pure oxygen (100%) returns O2', () { + const o2 = GasMix(o2: 100.0, he: 0.0); + expect(o2.name, 'O2'); + }); + + test('99% O2 with no helium returns O2', () { + const o2 = GasMix(o2: 99.0, he: 0.0); + expect(o2.name, 'O2'); + }); + + test('trimix takes precedence over oxygen label', () { + const trimixHighO2 = GasMix(o2: 99.0, he: 1.0); + expect(trimixHighO2.name, startsWith('Tx')); + }); + + test('air returns Air', () { + const air = GasMix(o2: 21.0, he: 0.0); + expect(air.name, 'Air'); + }); + }); + + group('GasMix.isOxygen', () { + test('100% O2 with no helium is oxygen', () { + const o2 = GasMix(o2: 100.0, he: 0.0); + expect(o2.isOxygen, isTrue); + }); + + test('99% O2 with no helium is oxygen', () { + const o2 = GasMix(o2: 99.0, he: 0.0); + expect(o2.isOxygen, isTrue); + }); + + test('98% O2 is not oxygen', () { + const almostO2 = GasMix(o2: 98.0, he: 0.0); + expect(almostO2.isOxygen, isFalse); + }); + + test('100% O2 with helium is not oxygen (trimix)', () { + const heliox = GasMix(o2: 100.0, he: 1.0); + expect(heliox.isOxygen, isFalse); + }); + + test('air is not oxygen', () { + const air = GasMix(o2: 21.0, he: 0.0); + expect(air.isOxygen, isFalse); + }); }); group('GasMix.mnd', () { diff --git a/test/features/dive_log/presentation/pages/dive_detail_page_test.dart b/test/features/dive_log/presentation/pages/dive_detail_page_test.dart index e6ff0b1e3..52c6a9ba7 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_page_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_page_test.dart @@ -12,6 +12,40 @@ import 'package:submersion/l10n/arb/app_localizations.dart'; import '../../../../helpers/mock_providers.dart'; +// --------------------------------------------------------------------------- +// Helpers shared by gas-segment tests +// --------------------------------------------------------------------------- + +Widget _buildDetailPage(Dive dive, List overrides) { + return ProviderScope( + overrides: [ + ...overrides, + diveProvider(dive.id).overrideWith((ref) async => dive), + diveDataSourcesProvider( + dive.id, + ).overrideWith((ref) async => []), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DiveDetailPage(diveId: dive.id, embedded: true), + ), + ); +} + +Future _pumpDetailPage(WidgetTester tester, Dive dive) async { + final overrides = await getBaseOverrides(); + final originalOnError = FlutterError.onError; + FlutterError.onError = (d) { + if (d.toString().contains('overflowed')) return; + originalOnError?.call(d); + }; + await tester.pumpWidget(_buildDetailPage(dive, overrides)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + FlutterError.onError = originalOnError; +} + void main() { group('DiveDetailPage bottomTime coverage', () { testWidgets('displays bottomTime in stat row', (tester) async { @@ -195,4 +229,98 @@ void main() { expect(find.byType(DiveDetailPage), findsOneWidget); }); }); + + // ========================================================================= + // Gas segments wiring — exercises the inline buildGasUsageSegments logic + // added to DiveDetailPage._buildProfilePanel in the latest commit. + // ========================================================================= + + group('DiveDetailPage - gas segments wiring', () { + Dive makeDiveWithTanksAndProfile() { + return Dive( + id: 'dive-gas-tanks', + diveNumber: 1, + dateTime: DateTime(2026, 5, 4, 10, 0), + entryTime: DateTime(2026, 5, 4, 10, 5), + exitTime: DateTime(2026, 5, 4, 10, 55), + bottomTime: const Duration(minutes: 45), + runtime: const Duration(minutes: 50), + maxDepth: 25.0, + avgDepth: 18.0, + waterTemp: 22.0, + tanks: [ + const DiveTank( + id: 'tank-1', + startPressure: 200, + endPressure: 80, + gasMix: GasMix(o2: 21), + ), + ], + profile: [ + const DiveProfilePoint(timestamp: 0, depth: 0), + const DiveProfilePoint(timestamp: 300, depth: 15), + const DiveProfilePoint(timestamp: 2700, depth: 20), + const DiveProfilePoint(timestamp: 3000, depth: 0), + ], + equipment: const [], + notes: '', + photoIds: const [], + sightings: const [], + weights: const [], + tags: const [], + ); + } + + testWidgets('renders without crash when dive has tanks and a profile', ( + tester, + ) async { + final dive = makeDiveWithTanksAndProfile(); + await _pumpDetailPage(tester, dive); + expect(find.byType(DiveDetailPage), findsOneWidget); + }); + + testWidgets( + 'renders without crash when dive has no tanks (null gas path)', + (tester) async { + final dive = createTestDiveWithBottomTime(); + await _pumpDetailPage(tester, dive); + expect(find.byType(DiveDetailPage), findsOneWidget); + }, + ); + + testWidgets( + 'renders without crash when dive has tanks but empty profile (null diveDurationSeconds path)', + (tester) async { + final dive = Dive( + id: 'dive-notanks-noprofile', + diveNumber: 2, + dateTime: DateTime(2026, 5, 4, 11, 0), + entryTime: null, + exitTime: null, + bottomTime: const Duration(minutes: 30), + runtime: const Duration(minutes: 35), + maxDepth: 20.0, + avgDepth: 15.0, + waterTemp: null, + tanks: [ + const DiveTank( + id: 'tank-1', + startPressure: 200, + endPressure: 80, + gasMix: GasMix(o2: 21), + ), + ], + profile: const [], + equipment: const [], + notes: '', + photoIds: const [], + sightings: const [], + weights: const [], + tags: const [], + ); + await _pumpDetailPage(tester, dive); + expect(find.byType(DiveDetailPage), findsOneWidget); + }, + ); + }); } diff --git a/test/features/dive_log/presentation/providers/gas_switch_providers_test.dart b/test/features/dive_log/presentation/providers/gas_switch_providers_test.dart new file mode 100644 index 000000000..89811f727 --- /dev/null +++ b/test/features/dive_log/presentation/providers/gas_switch_providers_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; +import 'package:submersion/features/dive_log/presentation/providers/gas_switch_providers.dart'; + +GasSwitchWithTank _sw(double o2Fraction, {double heFraction = 0.0}) { + return GasSwitchWithTank( + gasSwitch: GasSwitch( + id: 'gs-1', + diveId: 'dive-1', + timestamp: 600, + tankId: 'tank-1', + createdAt: DateTime(2026, 1, 1), + ), + tankName: 'Tank', + gasMix: '', + o2Fraction: o2Fraction, + heFraction: heFraction, + ); +} + +void main() { + group('GasSwitchWithTankGasType.gasType', () { + test('air (21% O2, 0% He) returns air', () { + expect(_sw(0.21).gasType, GasType.air); + }); + + test('nitrox (32% O2, 0% He) returns nitrox', () { + expect(_sw(0.32).gasType, GasType.nitrox); + }); + + test('pure oxygen (100% O2, 0% He) returns oxygen', () { + expect(_sw(1.0).gasType, GasType.oxygen); + }); + + test('99% O2 with no helium returns oxygen', () { + expect(_sw(0.99).gasType, GasType.oxygen); + }); + + test('trimix (21% O2, 35% He) returns trimix', () { + expect(_sw(0.21, heFraction: 0.35).gasType, GasType.trimix); + }); + + test('trimix takes priority over oxygen (100% O2 + He)', () { + expect(_sw(1.0, heFraction: 0.01).gasType, GasType.trimix); + }); + + test('oxygen takes priority over nitrox (99% O2, no He)', () { + expect(_sw(0.99).gasType, GasType.oxygen); + }); + }); + + group('GasTypeFromFractions.gasType', () { + test('air fractions return air', () { + expect((o2: 0.21, he: 0.0).gasType, GasType.air); + }); + + test('nitrox fractions return nitrox', () { + expect((o2: 0.32, he: 0.0).gasType, GasType.nitrox); + }); + + test('pure oxygen fraction (1.0) returns oxygen', () { + expect((o2: 1.0, he: 0.0).gasType, GasType.oxygen); + }); + + test('0.99 O2 fraction returns oxygen', () { + expect((o2: 0.99, he: 0.0).gasType, GasType.oxygen); + }); + + test('trimix fractions return trimix', () { + expect((o2: 0.21, he: 0.35).gasType, GasType.trimix); + }); + + test('trimix takes priority over oxygen', () { + expect((o2: 1.0, he: 0.01).gasType, GasType.trimix); + }); + + test('0.22 O2 boundary is still air', () { + expect((o2: 0.22, he: 0.0).gasType, GasType.air); + }); + + test('above 0.22 O2 is nitrox', () { + expect((o2: 0.221, he: 0.0).gasType, GasType.nitrox); + }); + }); +} diff --git a/test/features/dive_log/presentation/providers/profile_legend_provider_test.dart b/test/features/dive_log/presentation/providers/profile_legend_provider_test.dart index 63ff8665a..6381af000 100644 --- a/test/features/dive_log/presentation/providers/profile_legend_provider_test.dart +++ b/test/features/dive_log/presentation/providers/profile_legend_provider_test.dart @@ -1,102 +1,169 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/core/constants/profile_metrics.dart'; -import 'package:submersion/features/dive_log/presentation/providers/profile_legend_provider.dart'; - -void main() { - group('ProfileLegendState', () { - group('sectionExpanded', () { - test('defaults to expected initial values', () { - const state = ProfileLegendState(); - expect(state.sectionExpanded['overlays'], true); - expect(state.sectionExpanded['decompression'], true); - expect(state.sectionExpanded['markers'], false); - expect(state.sectionExpanded['gasAnalysis'], false); - expect(state.sectionExpanded['other'], false); - expect(state.sectionExpanded['tankPressures'], true); - }); - - test('copyWith preserves sectionExpanded', () { - const state = ProfileLegendState(); - final updated = state.copyWith( - sectionExpanded: {...state.sectionExpanded, 'markers': true}, - ); - expect(updated.sectionExpanded['markers'], true); - expect(updated.sectionExpanded['overlays'], true); - }); - - test('equality includes sectionExpanded', () { - const state1 = ProfileLegendState(); - final state2 = state1.copyWith( - sectionExpanded: {...state1.sectionExpanded, 'markers': true}, - ); - expect(state1, isNot(equals(state2))); - }); - }); - }); - - group('ProfileLegend notifier methods (via state)', () { - group('explicit source set methods', () { - test('setCeilingSource sets to computer', () { - const state = ProfileLegendState(); - expect(state.ceilingSource, MetricDataSource.calculated); - final updated = state.copyWith( - ceilingSource: MetricDataSource.computer, - ); - expect(updated.ceilingSource, MetricDataSource.computer); - }); - - test('setNdlSource sets to computer', () { - const state = ProfileLegendState(); - expect(state.ndlSource, MetricDataSource.calculated); - final updated = state.copyWith(ndlSource: MetricDataSource.computer); - expect(updated.ndlSource, MetricDataSource.computer); - }); - }); - - group('activeSecondaryCount', () { - test('includes showCeiling in count', () { - const isolatedState = ProfileLegendState( - showCeiling: true, - showAscentRateColors: false, - showEvents: false, - showMaxDepthMarker: false, - showPressureMarkers: false, - showGasSwitchMarkers: false, - ); - expect(isolatedState.activeSecondaryCount, 1); - }); - - test('does NOT include showEvents in count', () { - const state = ProfileLegendState( - showEvents: true, - showAscentRateColors: false, - showMaxDepthMarker: false, - showPressureMarkers: false, - showGasSwitchMarkers: false, - showCeiling: false, - ); - expect(state.activeSecondaryCount, 0); - }); - }); - - group('toggleSection', () { - test('toggles a collapsed section to expanded', () { - const state = ProfileLegendState(); - expect(state.sectionExpanded['markers'], false); - final updated = state.copyWith( - sectionExpanded: {...state.sectionExpanded, 'markers': true}, - ); - expect(updated.sectionExpanded['markers'], true); - }); - - test('toggles an expanded section to collapsed', () { - const state = ProfileLegendState(); - expect(state.sectionExpanded['overlays'], true); - final updated = state.copyWith( - sectionExpanded: {...state.sectionExpanded, 'overlays': false}, - ); - expect(updated.sectionExpanded['overlays'], false); - }); - }); - }); -} +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/profile_metrics.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/presentation/providers/profile_legend_provider.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +class _StubSettingsNotifier extends StateNotifier + implements SettingsNotifier { + _StubSettingsNotifier() : super(const AppSettings()); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('ProfileLegendState', () { + group('sectionExpanded', () { + test('defaults to expected initial values', () { + const state = ProfileLegendState(); + expect(state.sectionExpanded['overlays'], true); + expect(state.sectionExpanded['decompression'], true); + expect(state.sectionExpanded['markers'], false); + expect(state.sectionExpanded['gasAnalysis'], false); + expect(state.sectionExpanded['other'], false); + expect(state.sectionExpanded['tankPressures'], true); + }); + + test('copyWith preserves sectionExpanded', () { + const state = ProfileLegendState(); + final updated = state.copyWith( + sectionExpanded: {...state.sectionExpanded, 'markers': true}, + ); + expect(updated.sectionExpanded['markers'], true); + expect(updated.sectionExpanded['overlays'], true); + }); + + test('equality includes sectionExpanded', () { + const state1 = ProfileLegendState(); + final state2 = state1.copyWith( + sectionExpanded: {...state1.sectionExpanded, 'markers': true}, + ); + expect(state1, isNot(equals(state2))); + }); + }); + }); + + group('ProfileLegend notifier methods (via state)', () { + group('explicit source set methods', () { + test('setCeilingSource sets to computer', () { + const state = ProfileLegendState(); + expect(state.ceilingSource, MetricDataSource.calculated); + final updated = state.copyWith( + ceilingSource: MetricDataSource.computer, + ); + expect(updated.ceilingSource, MetricDataSource.computer); + }); + + test('setNdlSource sets to computer', () { + const state = ProfileLegendState(); + expect(state.ndlSource, MetricDataSource.calculated); + final updated = state.copyWith(ndlSource: MetricDataSource.computer); + expect(updated.ndlSource, MetricDataSource.computer); + }); + }); + + group('activeSecondaryCount', () { + test('includes showCeiling in count', () { + const isolatedState = ProfileLegendState( + showCeiling: true, + showAscentRateColors: false, + showEvents: false, + showMaxDepthMarker: false, + showPressureMarkers: false, + showGasSwitchMarkers: false, + ); + expect(isolatedState.activeSecondaryCount, 1); + }); + + test('does NOT include showEvents in count', () { + const state = ProfileLegendState( + showEvents: true, + showAscentRateColors: false, + showMaxDepthMarker: false, + showPressureMarkers: false, + showGasSwitchMarkers: false, + showCeiling: false, + ); + expect(state.activeSecondaryCount, 0); + }); + }); + + group('toggleSection', () { + test('toggles a collapsed section to expanded', () { + const state = ProfileLegendState(); + expect(state.sectionExpanded['markers'], false); + final updated = state.copyWith( + sectionExpanded: {...state.sectionExpanded, 'markers': true}, + ); + expect(updated.sectionExpanded['markers'], true); + }); + + test('toggles an expanded section to collapsed', () { + const state = ProfileLegendState(); + expect(state.sectionExpanded['overlays'], true); + final updated = state.copyWith( + sectionExpanded: {...state.sectionExpanded, 'overlays': false}, + ); + expect(updated.sectionExpanded['overlays'], false); + }); + }); + + group('showGas', () { + test('defaults to true', () { + const state = ProfileLegendState(); + expect(state.showGas, isTrue); + }); + + test('copyWith sets showGas to false', () { + const state = ProfileLegendState(); + final updated = state.copyWith(showGas: false); + expect(updated.showGas, isFalse); + }); + + test('copyWith without showGas preserves current value', () { + const state = ProfileLegendState(showGas: false); + final updated = state.copyWith(showMaxDepthMarker: true); + expect(updated.showGas, isFalse); + }); + + test('equality distinguishes showGas true vs false', () { + const stateOn = ProfileLegendState(showGas: true); + const stateOff = ProfileLegendState(showGas: false); + expect(stateOn, isNot(equals(stateOff))); + }); + + test('states with same showGas value are equal (other fields equal)', () { + const a = ProfileLegendState(showGas: false); + const b = ProfileLegendState(showGas: false); + expect(a, equals(b)); + }); + }); + }); + + group('ProfileLegend.toggleGas', () { + ProviderContainer makeContainer() => ProviderContainer( + overrides: [ + settingsProvider.overrideWith((ref) => _StubSettingsNotifier()), + ], + ); + + test('toggles showGas from true to false', () { + final container = makeContainer(); + addTearDown(container.dispose); + final notifier = container.read(profileLegendProvider.notifier); + expect(container.read(profileLegendProvider).showGas, isTrue); + notifier.toggleGas(); + expect(container.read(profileLegendProvider).showGas, isFalse); + }); + + test('toggles showGas from false back to true', () { + final container = makeContainer(); + addTearDown(container.dispose); + final notifier = container.read(profileLegendProvider.notifier); + notifier.toggleGas(); + notifier.toggleGas(); + expect(container.read(profileLegendProvider).showGas, isTrue); + }); + }); +} diff --git a/test/features/dive_log/presentation/widgets/dive_profile_chart_test.dart b/test/features/dive_log/presentation/widgets/dive_profile_chart_test.dart index 4dc05429c..a9fe62d43 100644 --- a/test/features/dive_log/presentation/widgets/dive_profile_chart_test.dart +++ b/test/features/dive_log/presentation/widgets/dive_profile_chart_test.dart @@ -6,11 +6,13 @@ import 'package:submersion/core/constants/map_style.dart'; import 'package:submersion/core/deco/ascent_rate_calculator.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/data/services/gas_usage_segments_service.dart'; import 'package:submersion/features/dive_log/data/services/profile_markers_service.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; import 'package:submersion/features/dive_log/domain/entities/profile_event.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_chart.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/gas_timeline_strip.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -103,6 +105,8 @@ Widget _buildChart({ bool showMaxDepthMarker = false, bool showPressureThresholdMarkers = false, List? gasSwitches, + List? gasSegments, + int? diveDurationSeconds, bool tooltipBelow = false, void Function(List? rows)? onTooltipData, void Function(int? index)? onPointSelected, @@ -150,6 +154,8 @@ Widget _buildChart({ showMaxDepthMarker: showMaxDepthMarker, showPressureThresholdMarkers: showPressureThresholdMarkers, gasSwitches: gasSwitches, + gasSegments: gasSegments, + diveDurationSeconds: diveDurationSeconds, tooltipBelow: tooltipBelow, onTooltipData: onTooltipData, onPointSelected: onPointSelected, @@ -1098,6 +1104,142 @@ void main() { test('rightAxisSize returns larger value for wide width', () { expect(DiveProfileChart.rightAxisSize(500), 38.0); }); + + test('gasTimelineHeight constant is 22.0', () { + expect(DiveProfileChart.gasTimelineHeight, 22.0); + }); + }); + + // ========================================================================= + // Gas timeline strip integration + // ========================================================================= + + group('DiveProfileChart - gas timeline strip', () { + List makeSegments() => [ + const GasUsageSegment( + startSeconds: 0, + endSeconds: 150, + gasMix: GasMix(o2: 21), + label: 'Air', + ), + const GasUsageSegment( + startSeconds: 150, + endSeconds: 300, + gasMix: GasMix(o2: 50), + label: 'EAN50', + ), + ]; + + testWidgets( + 'renders GasTimelineStrip when segments and duration provided', + (tester) async { + await tester.pumpWidget( + _buildChart(gasSegments: makeSegments(), diveDurationSeconds: 300), + ); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsOneWidget); + }, + ); + + testWidgets('does not render GasTimelineStrip when segments is empty', ( + tester, + ) async { + await tester.pumpWidget( + _buildChart(gasSegments: const [], diveDurationSeconds: 300), + ); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsNothing); + }); + + testWidgets( + 'does not render GasTimelineStrip when diveDurationSeconds is null', + (tester) async { + await tester.pumpWidget( + _buildChart(gasSegments: makeSegments(), diveDurationSeconds: null), + ); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsNothing); + }, + ); + + testWidgets( + 'does not render GasTimelineStrip when diveDurationSeconds is zero', + (tester) async { + await tester.pumpWidget( + _buildChart(gasSegments: makeSegments(), diveDurationSeconds: 0), + ); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsNothing); + }, + ); + + testWidgets('renders with gas strip and playback cursor extension', ( + tester, + ) async { + await tester.pumpWidget( + _buildChart( + gasSegments: makeSegments(), + diveDurationSeconds: 300, + playbackTimestamp: 150, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsOneWidget); + expect(find.byType(DiveProfileChart), findsOneWidget); + }); + + testWidgets( + 'renders with gas strip and highlighted timestamp cursor extension', + (tester) async { + await tester.pumpWidget( + _buildChart( + gasSegments: makeSegments(), + diveDurationSeconds: 300, + highlightedTimestamp: 90, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsOneWidget); + expect(find.byType(DiveProfileChart), findsOneWidget); + }, + ); + + testWidgets('renders with both cursors active over gas strip', ( + tester, + ) async { + await tester.pumpWidget( + _buildChart( + gasSegments: makeSegments(), + diveDurationSeconds: 300, + playbackTimestamp: 100, + highlightedTimestamp: 50, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsOneWidget); + }); + + testWidgets('gas strip renders alongside ceiling curve', (tester) async { + final profile = _makeProfile(points: 10); + await tester.pumpWidget( + _buildChart( + profile: profile, + gasSegments: makeSegments(), + diveDurationSeconds: 300, + ceilingCurve: List.generate(10, (i) => i < 5 ? 0.0 : i * 0.5), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsOneWidget); + }); + + testWidgets('chart renders correctly without gasSegments (no strip)', ( + tester, + ) async { + await tester.pumpWidget(_buildChart()); + await tester.pumpAndSettle(); + expect(find.byType(GasTimelineStrip), findsNothing); + }); }); // ========================================================================= diff --git a/test/features/dive_log/presentation/widgets/dive_profile_legend_test.dart b/test/features/dive_log/presentation/widgets/dive_profile_legend_test.dart index 0a391c967..47558c0f1 100644 --- a/test/features/dive_log/presentation/widgets/dive_profile_legend_test.dart +++ b/test/features/dive_log/presentation/widgets/dive_profile_legend_test.dart @@ -292,5 +292,86 @@ void main() { // Badge should show 2 expect(find.text('2'), findsOneWidget); }); + + testWidgets('badge includes gas strip when hasGasData is true', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: DiveProfileLegend( + config: const ProfileLegendConfig(hasGasData: true), + zoomLevel: 1.0, + onZoomIn: () {}, + onZoomOut: () {}, + onResetZoom: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + // showGas defaults to true, so gas contributes 1 to the badge + expect(find.text('1'), findsOneWidget); + }); + }); + + group('ProfileLegendConfig.hasSecondaryToggles', () { + test('is true when hasGasData is true', () { + const config = ProfileLegendConfig(hasGasData: true); + expect(config.hasSecondaryToggles, isTrue); + }); + + test('is false when only non-toggle fields are set', () { + const config = ProfileLegendConfig(); + expect(config.hasSecondaryToggles, isFalse); + }); + }); + + group('gas toggle in _ChartOptionsDialog', () { + testWidgets( + 'gas strip toggle appears in Overlays when hasGasData is true', + (tester) async { + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: DiveProfileLegend( + config: const ProfileLegendConfig(hasGasData: true), + zoomLevel: 1.0, + onZoomIn: () {}, + onZoomOut: () {}, + onResetZoom: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(find.text('Gases'), findsOneWidget); + }, + ); + + testWidgets('gas strip toggle is absent when hasGasData is false', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: DiveProfileLegend( + config: const ProfileLegendConfig(), + zoomLevel: 1.0, + onZoomIn: () {}, + onZoomOut: () {}, + onResetZoom: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.tune), findsNothing); + }); }); } diff --git a/test/features/dive_log/presentation/widgets/gas_colors_test.dart b/test/features/dive_log/presentation/widgets/gas_colors_test.dart index f0bb13888..1ac813f5a 100644 --- a/test/features/dive_log/presentation/widgets/gas_colors_test.dart +++ b/test/features/dive_log/presentation/widgets/gas_colors_test.dart @@ -14,14 +14,21 @@ void main() { expect(GasColors.nitrox, const Color(0xFF4CAF50)); }); + test('oxygen is blue', () { + expect(GasColors.oxygen, const Color(0xFF1976D2)); + }); + test('trimix is purple', () { expect(GasColors.trimix, const Color(0xFF9C27B0)); }); - test('all three colors are distinct', () { + test('all four colors are distinct', () { expect(GasColors.air, isNot(GasColors.nitrox)); + expect(GasColors.air, isNot(GasColors.oxygen)); expect(GasColors.air, isNot(GasColors.trimix)); + expect(GasColors.nitrox, isNot(GasColors.oxygen)); expect(GasColors.nitrox, isNot(GasColors.trimix)); + expect(GasColors.oxygen, isNot(GasColors.trimix)); }); }); @@ -34,6 +41,10 @@ void main() { expect(GasColors.forGasType(GasType.nitrox), GasColors.nitrox); }); + test('returns oxygen color for GasType.oxygen', () { + expect(GasColors.forGasType(GasType.oxygen), GasColors.oxygen); + }); + test('returns trimix color for GasType.trimix', () { expect(GasColors.forGasType(GasType.trimix), GasColors.trimix); }); @@ -55,11 +66,26 @@ void main() { expect(GasColors.forGasMix(mix), GasColors.trimix); }); + test('returns oxygen color for pure O2 (100%)', () { + const mix = GasMix(o2: 100, he: 0); + expect(GasColors.forGasMix(mix), GasColors.oxygen); + }); + + test('returns oxygen color for 99% O2', () { + const mix = GasMix(o2: 99, he: 0); + expect(GasColors.forGasMix(mix), GasColors.oxygen); + }); + test('trimix takes precedence over nitrox when both apply', () { // High O2 + He = trimix, not nitrox const mix = GasMix(o2: 32, he: 20); expect(GasColors.forGasMix(mix), GasColors.trimix); }); + + test('trimix takes precedence over oxygen', () { + const mix = GasMix(o2: 100, he: 1); + expect(GasColors.forGasMix(mix), GasColors.trimix); + }); }); group('GasColors.forMixPercent', () { @@ -79,9 +105,21 @@ void main() { expect(GasColors.forMixPercent(21, 35), GasColors.trimix); }); + test('returns oxygen for 99% O2', () { + expect(GasColors.forMixPercent(99, 0), GasColors.oxygen); + }); + + test('returns oxygen for 100% O2', () { + expect(GasColors.forMixPercent(100, 0), GasColors.oxygen); + }); + test('trimix takes precedence over nitrox', () { expect(GasColors.forMixPercent(32, 20), GasColors.trimix); }); + + test('trimix takes precedence over oxygen', () { + expect(GasColors.forMixPercent(100, 1), GasColors.trimix); + }); }); group('GasColors.forMixFraction', () { @@ -97,9 +135,21 @@ void main() { expect(GasColors.forMixFraction(0.32, 0), GasColors.nitrox); }); + test('returns oxygen for 0.99 O2 fraction', () { + expect(GasColors.forMixFraction(0.99, 0), GasColors.oxygen); + }); + + test('returns oxygen for 1.0 O2 fraction', () { + expect(GasColors.forMixFraction(1.0, 0), GasColors.oxygen); + }); + test('returns trimix when He fraction is present', () { expect(GasColors.forMixFraction(0.21, 0.35), GasColors.trimix); }); + + test('trimix takes precedence over oxygen', () { + expect(GasColors.forMixFraction(1.0, 0.01), GasColors.trimix); + }); }); group('GasColors.fillColor', () { diff --git a/test/features/dive_log/presentation/widgets/gas_timeline_strip_test.dart b/test/features/dive_log/presentation/widgets/gas_timeline_strip_test.dart new file mode 100644 index 000000000..6a2c05dda --- /dev/null +++ b/test/features/dive_log/presentation/widgets/gas_timeline_strip_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/data/services/gas_usage_segments_service.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/gas_colors.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/gas_timeline_strip.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +GasUsageSegment _seg({ + required int start, + required int end, + double o2 = 21, + double he = 0, + String? label, +}) { + final mix = GasMix(o2: o2, he: he); + return GasUsageSegment( + startSeconds: start, + endSeconds: end, + gasMix: mix, + label: label ?? mix.name, + ); +} + +/// Wraps [GasTimelineStrip] in a fixed-width box with explicit zero padding +/// so tests aren't affected by [DiveProfileChart] axis-size calculations. +Widget _strip( + List segments, + int duration, { + double width = 400, + double? visibleMin, + double? visibleMax, +}) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: width, + child: GasTimelineStrip( + segments: segments, + diveDurationSeconds: duration, + leftPadding: 0, + rightPadding: 0, + visibleMinSeconds: visibleMin, + visibleMaxSeconds: visibleMax, + ), + ), + ), + ); +} + +void main() { + group('GasTimelineStrip guards', () { + testWidgets('renders nothing when segments list is empty', (tester) async { + await tester.pumpWidget(_strip([], 1800)); + expect(find.byType(GasTimelineStrip), findsOneWidget); + // Guard returns SizedBox.shrink() — no Tooltip/segment blocks are built + expect( + find.descendant( + of: find.byType(GasTimelineStrip), + matching: find.byType(Tooltip), + ), + findsNothing, + ); + }); + + testWidgets('renders nothing when diveDurationSeconds is zero', ( + tester, + ) async { + await tester.pumpWidget(_strip([_seg(start: 0, end: 100)], 0)); + // Guard returns SizedBox.shrink() — no Tooltip/segment blocks are built + expect( + find.descendant( + of: find.byType(GasTimelineStrip), + matching: find.byType(Tooltip), + ), + findsNothing, + ); + }); + }); + + group('GasTimelineStrip rendering', () { + testWidgets('renders a Tooltip for each segment', (tester) async { + await tester.pumpWidget( + _strip([ + _seg(start: 0, end: 1500, o2: 21, label: 'Air'), + _seg(start: 1500, end: 3000, o2: 50, label: 'EAN50'), + ], 3000), + ); + expect(find.byType(Tooltip), findsNWidgets(2)); + expect( + find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Air'), + findsOneWidget, + ); + expect( + find.byWidgetPredicate((w) => w is Tooltip && w.message == 'EAN50'), + findsOneWidget, + ); + }); + + testWidgets('shows label text for a wide segment', (tester) async { + // 400px wide, single segment covering full dive → block is 400px > 36px + await tester.pumpWidget( + _strip([_seg(start: 0, end: 3000, label: 'Air')], 3000), + ); + expect(find.text('Air'), findsOneWidget); + }); + + testWidgets('hides label text for a narrow segment', (tester) async { + // 10% of 400px = 40px — but with a very short segment it should be ≤ 36px + // Use 8% → 32px wide block + await tester.pumpWidget( + _strip( + [ + _seg(start: 0, end: 240, label: 'Air'), + _seg(start: 240, end: 3000, label: 'EAN50'), + ], + 3000, + width: 400, + ), + ); + // 240/3000 * 400 = 32px — too narrow to show label + expect(find.text('Air'), findsNothing); + // EAN50 block is 2760/3000 * 400 = 368px — wide → label shown + expect(find.text('EAN50'), findsOneWidget); + }); + + testWidgets('uses correct color for air segment', (tester) async { + await tester.pumpWidget( + _strip([_seg(start: 0, end: 3000, o2: 21)], 3000), + ); + final container = tester.widget( + find + .descendant( + of: find.byType(Tooltip), + matching: find.byType(Container), + ) + .first, + ); + expect(container.color, GasColors.air); + }); + + testWidgets('uses correct color for nitrox segment', (tester) async { + await tester.pumpWidget( + _strip([_seg(start: 0, end: 3000, o2: 32)], 3000), + ); + final container = tester.widget( + find + .descendant( + of: find.byType(Tooltip), + matching: find.byType(Container), + ) + .first, + ); + expect(container.color, GasColors.nitrox); + }); + + testWidgets('uses correct color for oxygen segment', (tester) async { + await tester.pumpWidget( + _strip([_seg(start: 0, end: 3000, o2: 100)], 3000), + ); + final container = tester.widget( + find + .descendant( + of: find.byType(Tooltip), + matching: find.byType(Container), + ) + .first, + ); + expect(container.color, GasColors.oxygen); + }); + }); + + group('GasTimelineStrip zoom window', () { + testWidgets('segment fully outside visible window produces no block', ( + tester, + ) async { + await tester.pumpWidget( + _strip( + [_seg(start: 0, end: 500, label: 'Air')], + 3000, + visibleMin: 1000, + visibleMax: 3000, + ), + ); + // Block width = 0, so no Container is rendered + expect(find.text('Air'), findsNothing); + expect(find.byType(Tooltip), findsNothing); + }); + + testWidgets('segment partially in window is clipped to visible range', ( + tester, + ) async { + // Segment covers 0-1500, window shows 750-3000 → visible half + await tester.pumpWidget( + _strip( + [_seg(start: 0, end: 1500, label: 'Air')], + 3000, + visibleMin: 750, + visibleMax: 3000, + ), + ); + // The partial segment still renders (block width > 0) + expect(find.byType(Tooltip), findsOneWidget); + }); + + testWidgets('segment fully inside window renders normally', (tester) async { + await tester.pumpWidget( + _strip( + [_seg(start: 1000, end: 2000, label: 'EAN32')], + 3000, + visibleMin: 500, + visibleMax: 2500, + ), + ); + expect(find.byType(Tooltip), findsOneWidget); + }); + }); +}