import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/models/calibration.dart'; import '../../domain/models/tag.dart'; import '../../providers.dart'; void showCalibrationSheet( BuildContext context, int sensorId, String sensorDeviceId, ) { showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, isDismissible: true, enableDrag: true, builder: (ctx) => SizedBox( height: MediaQuery.of(ctx).size.height * 0.95, child: CalibrationSheet( sensorId: sensorId, sensorDeviceId: sensorDeviceId, ), ), ); } class CalibrationSheet extends ConsumerStatefulWidget { const CalibrationSheet({ super.key, required this.sensorId, required this.sensorDeviceId, }); final int sensorId; final String sensorDeviceId; @override ConsumerState createState() => _CalibrationSheetState(); } class _CalibrationSheetState extends ConsumerState { late final PageController _pageController; late final ({int id, String deviceId}) _key; bool _calibrationComplete = false; @override void initState() { super.initState(); _pageController = PageController(); _key = (id: widget.sensorId, deviceId: widget.sensorDeviceId); } @override void dispose() { _pageController.dispose(); super.dispose(); } Future _begin() async { await ref.read(calibrationProvider(_key).notifier).beginCalibration(); if (mounted) { _pageController.animateToPage( 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } } Future _commitTag() async { await ref.read(calibrationProvider(_key).notifier).commitTag(); if (mounted) { _pageController.animateToPage( 2, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } } Future _finish() async { await ref.read(calibrationProvider(_key).notifier).finishCalibration(); if (mounted) { _pageController.animateToPage( 3, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } } void _cancelAndClose(BuildContext ctx) { _calibrationComplete = true; // suppress PopScope cancel ref.read(calibrationProvider(_key).notifier).cancelCalibration(); Navigator.of(ctx, rootNavigator: true).pop(); } void _done(BuildContext ctx) { _calibrationComplete = true; Navigator.of(ctx, rootNavigator: true).pop(); } @override Widget build(BuildContext context) { final state = ref.watch(calibrationProvider(_key)); return PopScope( canPop: true, onPopInvokedWithResult: (didPop, _) { if (didPop && !_calibrationComplete) { ref.read(calibrationProvider(_key).notifier).cancelCalibration(); } }, child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), child: Scaffold( body: PageView( controller: _pageController, physics: const NeverScrollableScrollPhysics(), children: [ _IntroPage( onBegin: _begin, onCancel: () => _cancelAndClose(context), ), _TagSelectionPage( state: state, sensorKey: _key, onNext: _commitTag, ), _CollectingPage( state: state, sensorKey: _key, onFinish: _finish, ), _DonePage( state: state, onDone: () => _done(context), ), ], ), ), ), ); } } // --------------------------------------------------------------------------- // Page 1 — Intro // --------------------------------------------------------------------------- class _IntroPage extends StatelessWidget { const _IntroPage({required this.onBegin, required this.onCancel}); final VoidCallback onCancel; final Future Function() onBegin; @override Widget build(BuildContext context) { final theme = Theme.of(context); return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 32, height: 4, decoration: BoxDecoration( color: theme.colorScheme.outlineVariant, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 32), Icon( Icons.radar, size: 56, color: theme.colorScheme.primary, ), const SizedBox(height: 24), Text( 'Calibrate sensor', style: theme.textTheme.headlineSmall ?.copyWith(fontWeight: FontWeight.w600), textAlign: TextAlign.center, ), const SizedBox(height: 16), Text( 'Calibration improves distance estimation accuracy. You will hold a tag at a series of known distances from the sensor while it collects readings.', style: theme.textTheme.bodyMedium ?.copyWith(color: theme.colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( 'You will need at least two distance measurements to complete calibration. More distances improve accuracy.', style: theme.textTheme.bodyMedium ?.copyWith(color: theme.colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( 'Keep the path between the tag and sensor unobstructed during each measurement.', style: theme.textTheme.bodyMedium ?.copyWith(color: theme.colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const Spacer(), _AsyncButton( onPressed: onBegin, label: 'Get started', icon: Icons.arrow_forward, ), const SizedBox(height: 12), TextButton( onPressed: onCancel, child: const Text('Cancel'), ), ], ), ), ); } } // --------------------------------------------------------------------------- // Page 2 — Tag selection // --------------------------------------------------------------------------- class _TagSelectionPage extends ConsumerWidget { const _TagSelectionPage({ required this.state, required this.sensorKey, required this.onNext, }); final CalibrationState state; final ({int id, String deviceId}) sensorKey; final Future Function() onNext; @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final notifier = ref.read(calibrationProvider(sensorKey).notifier); final tagsAsync = ref.watch(tagsProvider); final tags = tagsAsync.whenOrNull(data: (t) => t) ?? const []; final readings = state.tagRssiReadings; // Sort: tags with readings by RSSI descending (nearest first), // then tags without readings by id. final sorted = [...tags]..sort((a, b) { final ra = readings[a.tagId]; final rb = readings[b.tagId]; if (ra != null && rb != null) return rb.compareTo(ra); if (ra != null) return -1; if (rb != null) return 1; return a.id.compareTo(b.id); }); return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 32, height: 4, decoration: BoxDecoration( color: theme.colorScheme.outlineVariant, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 20), const Text( 'Select your tag', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500), textAlign: TextAlign.center, ), const SizedBox(height: 4), Text( 'Hold each tag near the sensor — the one you\'re using will show a stronger signal.', style: TextStyle( fontSize: 13, color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), const SizedBox(height: 16), Expanded( child: tags.isEmpty ? Center( child: Text( 'No tags enrolled', style: TextStyle( color: theme.colorScheme.onSurfaceVariant), ), ) : ListView.builder( itemCount: sorted.length, itemBuilder: (context, i) { final tag = sorted[i]; final rssi = readings[tag.tagId]; final selected = tag.tagId == state.selectedTagId; return _TagListTile( key: ValueKey(tag.tagId), tag: tag, rssi: rssi, selected: selected, onTap: () => notifier.selectTag(tag.tagId), ); }, ), ), const SizedBox(height: 16), _AsyncButton( onPressed: onNext, enabled: state.selectedTagId != null, label: 'Next', icon: Icons.arrow_forward, ), ], ), ), ); } } class _TagListTile extends StatelessWidget { const _TagListTile({ super.key, required this.tag, required this.rssi, required this.selected, required this.onTap, }); final Tag tag; final double? rssi; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return ListTile( selected: selected, selectedTileColor: cs.primaryContainer, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), leading: Icon( selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: selected ? cs.primary : cs.outline, ), title: Text(tag.name), subtitle: Text( tag.tagId, style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), ), trailing: rssi != null ? TweenAnimationBuilder( tween: Tween(begin: rssi, end: rssi), duration: const Duration(milliseconds: 300), builder: (context, value, child) => Chip( label: Text( '${value.round()} dBm', style: const TextStyle(fontFamily: 'monospace', fontSize: 12), ), backgroundColor: cs.primaryContainer, side: BorderSide.none, padding: const EdgeInsets.symmetric(horizontal: 4), ), ) : Chip( label: Text( '—', style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), ), side: BorderSide.none, padding: const EdgeInsets.symmetric(horizontal: 4), ), onTap: onTap, ); } } // --------------------------------------------------------------------------- // Page 3 — Collecting // --------------------------------------------------------------------------- class _CollectingPage extends ConsumerWidget { const _CollectingPage({ required this.state, required this.sensorKey, required this.onFinish, }); final CalibrationState state; final ({int id, String deviceId}) sensorKey; final Future Function() onFinish; @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final notifier = ref.read(calibrationProvider(sensorKey).notifier); final collecting = state.phase == CalibrationPhase.collecting; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Drag handle Center( child: Container( width: 32, height: 4, decoration: BoxDecoration( color: theme.colorScheme.outlineVariant, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 20), // Dot-dash stage indicator _StageIndicator( distances: calibrationDistances, completedDistances: state.completedDistances, selectedDistance: state.selectedDistance, ), const SizedBox(height: 16), // Title + subtitle Text( collecting ? 'Hold steady' : state.selectedDistance != null ? 'Step to ${_fmtDist(state.selectedDistance!)} metres' : 'Select a distance', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), const SizedBox(height: 4), Text( collecting ? "Don't move until the ring fills." : 'Place or hold the tag in unobstructed view of the sensor antenna.', style: TextStyle( fontSize: 13, color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), const SizedBox(height: 16), // Distance chips _DistanceChips( distances: calibrationDistances, completedDistances: state.completedDistances, selectedDistance: state.selectedDistance, enabled: !collecting, onSelect: notifier.selectDistance, ), const SizedBox(height: 24), // Ring + pulse Center( child: _RingArea( state: state, onStart: collecting ? null : notifier.startStage, ), ), const SizedBox(height: 20), // Stats row _StatsRow( latestRssi: state.latestRssi, samples: state.samplesCollected, avgRssi: state.avgRssi, ), const SizedBox(height: 16), // Waveform SizedBox( height: 72, child: CustomPaint( painter: _WaveformPainter( readings: state.waveform, color: theme.colorScheme.primary, ), ), ), const Spacer(), // Finish button (shown when >= 2 stages done) if (state.canFinish) _AsyncButton( onPressed: onFinish, label: 'Finish calibration', icon: Icons.check, ), ], ), ), ); } } String _fmtDist(double d) => d == d.roundToDouble() ? d.toInt().toString() : d.toString(); // --------------------------------------------------------------------------- // Page 3 — Done // --------------------------------------------------------------------------- class _DonePage extends StatelessWidget { const _DonePage({required this.state, required this.onDone}); final CalibrationState state; final VoidCallback onDone; @override Widget build(BuildContext context) { final theme = Theme.of(context); final rssiRef = state.resultRssiRef; final exp = state.resultPathLossExp; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 32, height: 4, decoration: BoxDecoration( color: theme.colorScheme.outlineVariant, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 32), Icon( Icons.check_circle_rounded, size: 80, color: Colors.green.shade600, ), const SizedBox(height: 20), Text( 'Calibration complete', style: theme.textTheme.headlineSmall ?.copyWith(fontWeight: FontWeight.w600), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( 'The sensor model has been updated with your measurements.', style: theme.textTheme.bodyMedium ?.copyWith(color: theme.colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: 24), if (rssiRef != null && exp != null) ...[ _ResultRow( label: 'RSSI at 1 m (A)', value: '${rssiRef.toStringAsFixed(1)} dBm', ), _ResultRow( label: 'Path loss exponent (n)', value: exp.toStringAsFixed(3), ), const SizedBox(height: 24), SizedBox( height: 160, child: CustomPaint( painter: _ModelCurvePainter( rssiRef: rssiRef, pathLossExp: exp, color: theme.colorScheme.primary, gridColor: theme.colorScheme.outlineVariant, labelColor: theme.colorScheme.onSurfaceVariant, ), ), ), ], const Spacer(), FilledButton( style: FilledButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), onPressed: onDone, child: const Text('Done'), ), ], ), ), ); } } // --------------------------------------------------------------------------- // Sub-widgets // --------------------------------------------------------------------------- class _StageIndicator extends StatelessWidget { const _StageIndicator({ required this.distances, required this.completedDistances, required this.selectedDistance, }); final List distances; final Set completedDistances; final double? selectedDistance; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: distances.map((d) { final isCompleted = completedDistances.contains(d); final isCurrent = d == selectedDistance && !isCompleted; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: AnimatedContainer( duration: const Duration(milliseconds: 300), width: isCurrent ? 24 : 8, height: 8, decoration: BoxDecoration( color: isCompleted ? Colors.green.shade500 : isCurrent ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outlineVariant, borderRadius: BorderRadius.circular(4), ), ), ); }).toList(), ); } } class _DistanceChips extends StatelessWidget { const _DistanceChips({ required this.distances, required this.completedDistances, required this.selectedDistance, required this.enabled, required this.onSelect, }); final List distances; final Set completedDistances; final double? selectedDistance; final bool enabled; final void Function(double) onSelect; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Wrap( alignment: WrapAlignment.center, spacing: 8, children: distances.map((d) { final completed = completedDistances.contains(d); final selected = d == selectedDistance; return FilterChip( label: Text('${_fmtDist(d)} m'), avatar: completed ? const Icon(Icons.check, size: 16) : null, selected: selected, onSelected: enabled && !completed ? (_) => onSelect(d) : null, selectedColor: cs.primaryContainer, checkmarkColor: cs.onPrimaryContainer, side: completed ? BorderSide(color: Colors.green.shade100, width: 1.5) : null, labelStyle: TextStyle( color: completed ? Colors.black : selected ? cs.onPrimaryContainer : null, ), disabledColor: completed ? Colors.green.shade50 : null, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ); }).toList(), ); } } class _RingArea extends StatefulWidget { const _RingArea({required this.state, required this.onStart}); final CalibrationState state; final Future Function()? onStart; @override State<_RingArea> createState() => _RingAreaState(); } class _RingAreaState extends State<_RingArea> with TickerProviderStateMixin { final _pulses = []; @override void didUpdateWidget(_RingArea oldWidget) { super.didUpdateWidget(oldWidget); if (widget.state.phase == CalibrationPhase.collecting && widget.state.samplesCollected != oldWidget.state.samplesCollected) { _spawnPulse(); HapticFeedback.lightImpact(); } } void _spawnPulse() { final ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); setState(() => _pulses.add(ctrl)); ctrl.forward().then((_) { if (mounted) setState(() => _pulses.remove(ctrl)); ctrl.dispose(); }); } @override void dispose() { for (final c in _pulses) { c.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final collecting = widget.state.phase == CalibrationPhase.collecting; const size = 200.0; return SizedBox( width: size, height: size, child: Stack( alignment: Alignment.center, children: [ // One independent pulse ring per in-flight animation. for (final ctrl in _pulses) _PulseRing(controller: ctrl, color: theme.colorScheme.primary, size: size), // Ring CustomPaint( size: const Size(size, size), painter: _CalibrationRingPainter( progress: widget.state.progress, collecting: collecting, primaryColor: theme.colorScheme.primary, trackColor: theme.colorScheme.surfaceContainerHighest, ), ), // Center content if (collecting) Column( mainAxisSize: MainAxisSize.min, children: [ Text( widget.state.latestRssi != null ? widget.state.latestRssi!.round().toString() : '—', style: const TextStyle( fontFamily: 'monospace', fontSize: 32, fontWeight: FontWeight.w500, ), ), Text( 'dBm', style: TextStyle( fontSize: 13, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ) else if (widget.onStart != null) Positioned.fill( child: _AsyncButton( onPressed: widget.onStart!, label: 'Start', circular: true, ), ), ], ), ); } } class _PulseRing extends StatelessWidget { const _PulseRing({ required this.controller, required this.color, required this.size, }); final AnimationController controller; final Color color; final double size; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: controller, builder: (_, _) { final t = controller.value; final scale = 1.0 + t * 0.5; final opacity = (1.0 - t) * 0.5; return Transform.scale( scale: scale, child: Container( width: size, height: size, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: color.withValues(alpha: opacity), width: 3, ), ), ), ); }, ); } } class _StatsRow extends StatelessWidget { const _StatsRow({ required this.latestRssi, required this.samples, required this.avgRssi, }); final double? latestRssi; final int samples; final double? avgRssi; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _StatCell( value: latestRssi != null ? '${latestRssi!.round()}' : '—', label: 'current', ), _StatCell(value: '$samples', label: 'samples'), _StatCell( value: avgRssi != null ? '${avgRssi!.round()}' : '—', label: 'avg dBm', ), ], ); } } class _StatCell extends StatelessWidget { const _StatCell({required this.value, required this.label}); final String value; final String label; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( children: [ Text( value, style: const TextStyle( fontFamily: 'monospace', fontSize: 20, fontWeight: FontWeight.w500, ), ), Text( label, style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), ], ); } } class _ResultRow extends StatelessWidget { const _ResultRow({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Expanded( child: Text( label, style: Theme.of(context).textTheme.bodySmall, ), ), Text( value, style: const TextStyle(fontFamily: 'monospace'), ), ], ), ); } } /// Button that shows a CircularProgressIndicator while its async [onPressed] /// callback is running. class _AsyncButton extends StatefulWidget { const _AsyncButton({ required this.onPressed, required this.label, this.icon, this.compact = false, this.circular = false, this.enabled = true, }); final Future Function() onPressed; final String label; final IconData? icon; final bool compact; final bool circular; final bool enabled; @override State<_AsyncButton> createState() => _AsyncButtonState(); } class _AsyncButtonState extends State<_AsyncButton> { bool _loading = false; @override Widget build(BuildContext context) { final shape = RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ); final child = _loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : widget.icon != null ? Row( mainAxisSize: MainAxisSize.min, children: [ Text(widget.label), const SizedBox(width: 8), Icon(widget.icon, size: 18), ], ) : Text(widget.label, style: TextStyle(fontSize: 36, color: Theme.of(context).colorScheme.primary)); final canPress = !_loading && widget.enabled; if (widget.circular) { final cs = Theme.of(context).colorScheme; return SizedBox.expand( child: TextButton( style: TextButton.styleFrom( shape: const CircleBorder(), foregroundColor: cs.primary, overlayColor: cs.onSurface, padding: EdgeInsets.zero, ), onPressed: canPress ? _run : null, child: child, ), ); } if (widget.compact) { return TextButton( style: TextButton.styleFrom(shape: shape, textStyle: const TextStyle(fontSize: 36, color: Colors.black)), onPressed: canPress ? _run : null, child: child, ); } return FilledButton( style: FilledButton.styleFrom( shape: shape, minimumSize: const Size.fromHeight(48), ), onPressed: canPress ? _run : null, child: child, ); } Future _run() async { setState(() => _loading = true); try { await widget.onPressed(); } finally { if (mounted) setState(() => _loading = false); } } } // --------------------------------------------------------------------------- // Custom painters // --------------------------------------------------------------------------- class _CalibrationRingPainter extends CustomPainter { const _CalibrationRingPainter({ required this.progress, required this.collecting, required this.primaryColor, required this.trackColor, }); final double progress; final bool collecting; final Color primaryColor; final Color trackColor; @override void paint(Canvas canvas, Size size) { const strokeWidth = 12.0; final center = Offset(size.width / 2, size.height / 2); final radius = (size.width - strokeWidth) / 2; final rect = Rect.fromCircle(center: center, radius: radius); final trackPaint = Paint() ..color = trackColor ..strokeWidth = strokeWidth ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; canvas.drawArc(rect, 0, math.pi * 2, false, trackPaint); if (progress > 0 && collecting) { final arcPaint = Paint() ..color = primaryColor ..strokeWidth = strokeWidth ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; canvas.drawArc( rect, -math.pi / 2, math.pi * 2 * progress.clamp(0.0, 1.0), false, arcPaint, ); } } @override bool shouldRepaint(_CalibrationRingPainter old) => old.progress != progress || old.collecting != collecting; } class _WaveformPainter extends CustomPainter { const _WaveformPainter({required this.readings, required this.color}); final List readings; final Color color; static const _minRssi = -100.0; static const _maxRssi = -30.0; @override void paint(Canvas canvas, Size size) { if (readings.length < 2) return; final paint = Paint() ..color = color ..strokeWidth = 1.5 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round; final path = Path(); for (var i = 0; i < readings.length; i++) { final x = size.width * i / (readings.length - 1); final norm = ((readings[i] - _minRssi) / (_maxRssi - _minRssi)).clamp(0.0, 1.0); final y = size.height * (1 - norm); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, paint); } @override bool shouldRepaint(_WaveformPainter old) => old.readings != readings; } class _ModelCurvePainter extends CustomPainter { const _ModelCurvePainter({ required this.rssiRef, required this.pathLossExp, required this.color, required this.gridColor, required this.labelColor, }); final double rssiRef; final double pathLossExp; final Color color; final Color gridColor; final Color labelColor; static const _dMin = 0.1; static const _dMax = 10.0; static const _rssiMin = -100.0; static const _rssiMax = -20.0; double _rssi(double d) => rssiRef - 10 * pathLossExp * math.log(d) / math.ln10; @override void paint(Canvas canvas, Size size) { const padding = 32.0; final plotW = size.width - padding; final plotH = size.height - padding; // Grid lines & labels final gridPaint = Paint() ..color = gridColor ..strokeWidth = 0.5; final labelStyle = TextStyle(fontSize: 10, color: labelColor); for (final rssiVal in [-40.0, -60.0, -80.0]) { final norm = (_rssiMax - rssiVal) / (_rssiMax - _rssiMin); final y = padding + norm * plotH; canvas.drawLine(Offset(padding, y), Offset(size.width, y), gridPaint); _drawText(canvas, '${rssiVal.toInt()}', Offset(0, y - 6), labelStyle); } // Curve final curvePaint = Paint() ..color = color ..strokeWidth = 2 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round; final path = Path(); const steps = 100; for (var i = 0; i <= steps; i++) { final t = i / steps; final d = _dMin + t * (_dMax - _dMin); final rssi = _rssi(d).clamp(_rssiMin, _rssiMax); final x = padding + t * plotW; final norm = (_rssiMax - rssi) / (_rssiMax - _rssiMin); final y = padding + norm * plotH; if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, curvePaint); // X axis labels for (final dist in [1.0, 3.0, 5.0, 10.0]) { final t = (dist - _dMin) / (_dMax - _dMin); final x = padding + t * plotW; _drawText(canvas, '${dist.toInt()}m', Offset(x - 8, size.height - 14), labelStyle); } } void _drawText(Canvas canvas, String text, Offset offset, TextStyle style) { final tp = TextPainter( text: TextSpan(text: text, style: style), textDirection: TextDirection.ltr, )..layout(); tp.paint(canvas, offset); } @override bool shouldRepaint(_ModelCurvePainter old) => old.rssiRef != rssiRef || old.pathLossExp != pathLossExp; }