import 'dart:math' as math; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import '../../../../core/models/units.dart'; import '../../../../core/providers/sensor_history_provider.dart'; import '../../../theme/app_colors.dart'; /// One graph (requirements): line over the selected time window with the /// window's max/min top-left, current value and name bottom-left, value /// axis on the right, seconds-ago axis along the bottom, and a touch cursor /// that reads out the value under the finger. class SensorGraphPanel extends StatelessWidget { final SensorView view; final SensorHistory history; final int windowSeconds; final VoidCallback onRemove; const SensorGraphPanel({ super.key, required this.view, required this.history, required this.windowSeconds, required this.onRemove, }); @override Widget build(BuildContext context) { final c = AppColors.of(context); final raw = history.get(view.def.id); final now = raw.isEmpty ? 0.0 : raw.last.x; // x = seconds before the latest sample (0 at the right edge). final spots = [ for (final s in raw) if (now - s.x <= windowSeconds) FlSpot(s.x - now, view.convert(s.y)), ]; double? lo, hi; for (final s in spots) { lo = lo == null ? s.y : math.min(lo, s.y); hi = hi == null ? s.y : math.max(hi, s.y); } // Y axis follows the data in the window (like the reference), with a // margin, and never collapses to zero height for a steady value. var minY = view.min, maxY = view.max; if (lo != null && hi != null) { final pad = math.max((hi - lo) * 0.15, (view.max - view.min) * 0.02); minY = lo - pad; maxY = hi + pad; } final muted = TextStyle(color: c.onSurfaceMuted, fontSize: 11); String f(double v) => v.toStringAsFixed(view.decimals); return Container( height: 170, margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.fromLTRB(8, 6, 4, 4), decoration: BoxDecoration( color: c.surface, borderRadius: BorderRadius.circular(8), border: Border.all(color: c.divider), ), child: Stack( children: [ Padding( padding: const EdgeInsets.only(bottom: 4), child: spots.length < 2 ? Center(child: Text('Waiting for data…', style: muted)) : LineChart( LineChartData( minX: -windowSeconds.toDouble(), maxX: 0, minY: minY, maxY: maxY, clipData: const FlClipData.all(), gridData: FlGridData( show: true, getDrawingHorizontalLine: (_) => FlLine(color: c.divider, strokeWidth: 1), getDrawingVerticalLine: (_) => FlLine(color: c.divider, strokeWidth: 1), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( topTitles: const AxisTitles(), leftTitles: const AxisTitles(), rightTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (v, meta) => Text( _compact(v), style: muted.copyWith(fontSize: 9), ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 16, interval: windowSeconds / 6, getTitlesWidget: (v, meta) => Text( (-v).round().toString(), style: muted.copyWith(fontSize: 9), ), ), ), ), lineTouchData: LineTouchData( getTouchedSpotIndicator: (bar, indexes) => [ for (final _ in indexes) const TouchedSpotIndicatorData( FlLine(color: Colors.red, strokeWidth: 1.5), FlDotData(show: true), ), ], touchTooltipData: LineTouchTooltipData( getTooltipColor: (_) => Colors.red, tooltipPadding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2), getTooltipItems: (touched) => [ for (final t in touched) LineTooltipItem( f(t.y), const TextStyle( color: Colors.white, fontSize: 11), ), ], ), ), lineBarsData: [ LineChartBarData( spots: spots, isCurved: false, color: c.accent, barWidth: 1.5, dotData: const FlDotData(show: false), ), ], ), duration: Duration.zero, ), ), // Window max / min Positioned( left: 4, top: 0, child: IgnorePointer( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(hi == null ? '-' : f(hi), style: muted), Text(lo == null ? '-' : f(lo), style: muted), ], ), ), ), // Name + current value Positioned( left: 4, bottom: 18, child: IgnorePointer( child: RichText( text: TextSpan(children: [ TextSpan( text: '${view.name} ', style: TextStyle(color: c.accent, fontSize: 14), ), TextSpan( text: raw.isEmpty ? '-' : view.format(raw.last.y), style: TextStyle( color: c.onSurface, fontSize: 20, fontWeight: FontWeight.bold, ), ), TextSpan( text: ' ${view.unit}', style: TextStyle(color: c.onSurfaceMuted, fontSize: 12), ), ]), ), ), ), Positioned( right: 0, top: 0, child: GestureDetector( onTap: onRemove, child: Icon(Icons.close, size: 16, color: c.onSurfaceMuted), ), ), ], ), ); } static String _compact(double v) { if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}k'; if (v.abs() >= 10 || v == v.roundToDouble()) return v.toStringAsFixed(0); return v.toStringAsFixed(1); } }