diff --git a/lib/data/repositories/phoenix_sensor_repository.dart b/lib/data/repositories/phoenix_sensor_repository.dart index 3b94198..443ffbc 100644 --- a/lib/data/repositories/phoenix_sensor_repository.dart +++ b/lib/data/repositories/phoenix_sensor_repository.dart @@ -59,4 +59,36 @@ class PhoenixSensorRepository implements SensorRepository { Stream> sensorEvents() => realtime .channelMessages('sensors') .map((m) => {'event': m.event, ...m.payload}); + + @override + Future beginCalibration(int id) async { + final json = await client.beginCalibration(id); + return json['samples_needed'] as int; + } + + @override + Future startStage(int id, double distance) => + client.startStage(id, distance); + + @override + Future<({double rssiRef, double pathLossExp})> finishCalibration( + int id) async { + final json = await client.finishCalibration(id); + return ( + rssiRef: (json['rssi_ref'] as num).toDouble(), + pathLossExp: (json['path_loss_exp'] as num).toDouble(), + ); + } + + @override + Future cancelCalibration(int id) => client.cancelCalibration(id); + + @override + Stream<({String event, Map payload})> calibrationEvents( + String sensorDeviceId) => + realtime.channelMessages('calibration:$sensorDeviceId'); + + @override + void leaveCalibrationChannel(String sensorDeviceId) => + realtime.leaveChannel('calibration:$sensorDeviceId'); } diff --git a/lib/data/repositories/sensor_repository.dart b/lib/data/repositories/sensor_repository.dart index 0f807b3..ba2e61c 100644 --- a/lib/data/repositories/sensor_repository.dart +++ b/lib/data/repositories/sensor_repository.dart @@ -16,4 +16,30 @@ abstract class SensorRepository { /// Stream of raw SensorsChannel messages. Each map contains an `event` key /// (`sensor_announced` or `sensor_enrollment_timeout`) plus the payload. Stream> sensorEvents(); + + // --------------------------------------------------------------------------- + // Calibration + // --------------------------------------------------------------------------- + + /// Enters calibration mode on the sensor. Returns the number of samples the + /// server will collect per stage. + Future beginCalibration(int id); + + /// Starts a collection stage at [distance] metres. + Future startStage(int id, double distance); + + /// Runs least-squares regression over all completed stages and persists the + /// result. Returns the fitted (rssiRef, pathLossExp) pair. + Future<({double rssiRef, double pathLossExp})> finishCalibration(int id); + + /// Aborts calibration and discards all accumulated stage data. + Future cancelCalibration(int id); + + /// Real-time events from the server's CalibrationChannel. + /// Topic: `calibration:{sensorDeviceId}` (the string BLE device ID). + Stream<({String event, Map payload})> calibrationEvents( + String sensorDeviceId); + + /// Leaves the calibration Phoenix channel for [sensorDeviceId]. + void leaveCalibrationChannel(String sensorDeviceId); } diff --git a/lib/data/sources/localiser/realtime_data_client.dart b/lib/data/sources/localiser/realtime_data_client.dart index 13cffc7..ec5ede9 100644 --- a/lib/data/sources/localiser/realtime_data_client.dart +++ b/lib/data/sources/localiser/realtime_data_client.dart @@ -78,6 +78,12 @@ class RealtimeDataClient { .map((msg) => (event: msg.event.value, payload: msg.payload ?? const {})); } + /// Leaves [topic] and removes it from the channel cache. + void leaveChannel(String topic) { + final ch = _channels.remove(topic); + ch?.leave(); + } + /// Pushes [event] on [topic] and waits for the server reply. /// The channel must have been joined first via [channel]. Future> push( diff --git a/lib/data/sources/localiser/sensor_client.dart b/lib/data/sources/localiser/sensor_client.dart index c52dba4..835e76c 100644 --- a/lib/data/sources/localiser/sensor_client.dart +++ b/lib/data/sources/localiser/sensor_client.dart @@ -38,17 +38,22 @@ class SensorClient extends LocaliserdClient { }) as Map; - Future> startCalibration( - int id, - double referenceDistance, - ) async => - await post('/api/sensors/$id/calibration/start', { - 'reference_distance': referenceDistance, + Future> beginCalibration(int id) async => + await post('/api/sensors/$id/calibration/begin') as Map; + + Future> startStage(int id, double distance) async => + await post('/api/sensors/$id/calibration/stage', { + 'distance': distance, }) as Map; - Future> stopCalibration(int id) async => - await post('/api/sensors/$id/calibration/stop') as Map; + Future> finishCalibration(int id) async => + await post( + '/api/sensors/$id/calibration/finish', + ) as Map; + + Future cancelCalibration(int id) => + delete('/api/sensors/$id/calibration'); Future getVersion(int id) async { final response = diff --git a/lib/domain/models/calibration.dart b/lib/domain/models/calibration.dart new file mode 100644 index 0000000..6ecaa08 --- /dev/null +++ b/lib/domain/models/calibration.dart @@ -0,0 +1,71 @@ +enum CalibrationPhase { idle, ready, collecting, done, error } + +const calibrationDistances = [0.5, 1.0, 2.0, 3.0]; + +class CalibrationState { + const CalibrationState({ + this.phase = CalibrationPhase.idle, + this.samplesNeeded = 0, + this.samplesCollected = 0, + this.selectedDistance, + this.completedDistances = const {}, + this.waveform = const [], + this.latestRssi, + this.avgRssi, + this.resultRssiRef, + this.resultPathLossExp, + this.error, + }); + + final CalibrationPhase phase; + final int samplesNeeded; + final int samplesCollected; + final double? selectedDistance; + final Set completedDistances; + /// Circular buffer of recent RSSI readings for the waveform, max 30. + final List waveform; + final double? latestRssi; + final double? avgRssi; + final double? resultRssiRef; + final double? resultPathLossExp; + final String? error; + + bool get canFinish => + completedDistances.length >= 2 && phase != CalibrationPhase.collecting; + + double get progress => + samplesNeeded == 0 ? 0 : samplesCollected / samplesNeeded; + + CalibrationState copyWith({ + CalibrationPhase? phase, + int? samplesNeeded, + int? samplesCollected, + double? selectedDistance, + Set? completedDistances, + List? waveform, + double? latestRssi, + double? avgRssi, + double? resultRssiRef, + double? resultPathLossExp, + String? error, + bool clearSelectedDistance = false, + bool clearLatestRssi = false, + bool clearAvgRssi = false, + bool clearError = false, + }) => + CalibrationState( + phase: phase ?? this.phase, + samplesNeeded: samplesNeeded ?? this.samplesNeeded, + samplesCollected: samplesCollected ?? this.samplesCollected, + selectedDistance: clearSelectedDistance + ? null + : selectedDistance ?? this.selectedDistance, + completedDistances: completedDistances ?? this.completedDistances, + waveform: waveform ?? this.waveform, + latestRssi: clearLatestRssi ? null : latestRssi ?? this.latestRssi, + avgRssi: clearAvgRssi ? null : avgRssi ?? this.avgRssi, + resultRssiRef: resultRssiRef ?? this.resultRssiRef, + resultPathLossExp: resultPathLossExp ?? this.resultPathLossExp, + error: clearError ? null : error ?? this.error, + ); +} diff --git a/lib/features/sensors/calibration_notifier.dart b/lib/features/sensors/calibration_notifier.dart new file mode 100644 index 0000000..f14aa7e --- /dev/null +++ b/lib/features/sensors/calibration_notifier.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/repositories/sensor_repository.dart'; +import '../../domain/models/calibration.dart'; + +const _waveformCapacity = 30; + +class CalibrationNotifier extends StateNotifier { + CalibrationNotifier({ + required this.sensorId, + required this.sensorDeviceId, + required this.repo, + }) : super(const CalibrationState()); + + final int sensorId; + final String sensorDeviceId; + final SensorRepository repo; + + StreamSubscription<({String event, Map payload})>? _sub; + + /// Joins the calibration Phoenix channel and calls the begin REST endpoint. + /// Channel subscription is set up first to guarantee no reading events are + /// dropped before the server acknowledges the stage start. + Future beginCalibration() async { + // Subscribe first, then call REST. + _sub = repo + .calibrationEvents(sensorDeviceId) + .listen(_onEvent, onError: _onError); + + try { + final samplesNeeded = await repo.beginCalibration(sensorId); + final firstUncompleted = calibrationDistances.first; + state = state.copyWith( + phase: CalibrationPhase.ready, + samplesNeeded: samplesNeeded, + samplesCollected: 0, + selectedDistance: firstUncompleted, + completedDistances: {}, + waveform: [], + clearLatestRssi: true, + ); + } catch (e) { + _sub?.cancel(); + _sub = null; + state = state.copyWith( + phase: CalibrationPhase.error, + error: e.toString(), + ); + } + } + + void selectDistance(double distance) { + if (state.phase != CalibrationPhase.ready) return; + if (state.completedDistances.contains(distance)) return; + state = state.copyWith(selectedDistance: distance); + } + + Future startStage() async { + final distance = state.selectedDistance; + if (distance == null || state.phase != CalibrationPhase.ready) return; + state = state.copyWith( + phase: CalibrationPhase.collecting, + samplesCollected: 0, + waveform: [], + clearLatestRssi: true, + clearAvgRssi: true, + ); + try { + await repo.startStage(sensorId, distance); + } catch (e) { + state = state.copyWith( + phase: CalibrationPhase.error, + error: e.toString(), + ); + } + } + + Future finishCalibration() async { + try { + final result = await repo.finishCalibration(sensorId); + _sub?.cancel(); + _sub = null; + repo.leaveCalibrationChannel(sensorDeviceId); + state = state.copyWith( + phase: CalibrationPhase.done, + resultRssiRef: result.rssiRef, + resultPathLossExp: result.pathLossExp, + ); + } catch (e) { + state = state.copyWith( + phase: CalibrationPhase.error, + error: e.toString(), + ); + } + } + + Future cancelCalibration() async { + _sub?.cancel(); + _sub = null; + repo.leaveCalibrationChannel(sensorDeviceId); + try { + await repo.cancelCalibration(sensorId); + } catch (_) { + // Best-effort: ignore errors on cancel (sensor may already be idle). + } + state = const CalibrationState(); + } + + void _onEvent(({String event, Map payload}) msg) { + switch (msg.event) { + case 'reading': + _handleReading(msg.payload); + case 'stage_complete': + _handleStageComplete(msg.payload); + case 'cancelled': + state = const CalibrationState(); + default: + break; + } + } + + void _handleReading(Map payload) { + final rssi = (payload['rssi'] as num).toDouble(); + final progress = payload['stage_progress'] as Map; + final current = progress['current'] as int; + + final newWaveform = List.from(state.waveform); + if (newWaveform.length >= _waveformCapacity) newWaveform.removeAt(0); + newWaveform.add(rssi); + + final avg = newWaveform.reduce((a, b) => a + b) / newWaveform.length; + + state = state.copyWith( + latestRssi: rssi, + samplesCollected: current, + waveform: newWaveform, + avgRssi: avg, + ); + } + + void _handleStageComplete(Map payload) { + final distance = (payload['distance'] as num).toDouble(); + final meanRssi = (payload['mean_rssi'] as num).toDouble(); + + final completed = {...state.completedDistances, distance}; + final nextDistance = calibrationDistances + .where((d) => !completed.contains(d)) + .firstOrNull; + + HapticFeedback.mediumImpact(); + + state = state.copyWith( + phase: CalibrationPhase.ready, + completedDistances: completed, + avgRssi: meanRssi, + selectedDistance: nextDistance, + samplesCollected: 0, + waveform: [], + ); + } + + void _onError(Object error) { + state = state.copyWith( + phase: CalibrationPhase.error, + error: error.toString(), + ); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } +} diff --git a/lib/features/sensors/calibration_sheet.dart b/lib/features/sensors/calibration_sheet.dart new file mode 100644 index 0000000..cf26966 --- /dev/null +++ b/lib/features/sensors/calibration_sheet.dart @@ -0,0 +1,1015 @@ +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 '../../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 _finish() async { + await ref.read(calibrationProvider(_key).notifier).finishCalibration(); + if (mounted) { + _pageController.animateToPage( + 2, + 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), + ), + _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 — 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) + _AsyncButton( + onPressed: widget.onStart!, + label: 'Start', + compact: 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, + }); + + final Future Function() onPressed; + final String label; + final IconData? icon; + final bool compact; + + @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); + + if (widget.compact) { + return TextButton( + style: TextButton.styleFrom(shape: shape, textStyle: const .new(fontSize: 32)), + onPressed: _loading ? null : _run, + child: child, + ); + } + + return FilledButton( + style: FilledButton.styleFrom( + shape: shape, + minimumSize: const Size.fromHeight(48), + ), + onPressed: _loading ? null : _run, + 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; +} diff --git a/lib/features/sensors/sensor_detail_sheet.dart b/lib/features/sensors/sensor_detail_sheet.dart index 9749ff8..201df59 100644 --- a/lib/features/sensors/sensor_detail_sheet.dart +++ b/lib/features/sensors/sensor_detail_sheet.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../domain/models/sensor.dart'; import '../../providers.dart'; +import 'calibration_sheet.dart'; void showSensorDetailSheet(BuildContext context, int sensorId) { showModalBottomSheet( @@ -125,6 +126,10 @@ class _SensorDetailSheetState extends ConsumerState { }), onSaveName: () => _saveName(sensor), onPlace: () => _placeOnFloorPlan(context, sensor), + onCalibrate: () { + Navigator.of(context, rootNavigator: true).pop(); + showCalibrationSheet(context, sensor.id, sensor.sensorId); + }, onDelete: () => _delete(context, sensor), ); }, @@ -142,6 +147,7 @@ class _SheetBody extends StatelessWidget { required this.onEditToggle, required this.onSaveName, required this.onPlace, + required this.onCalibrate, required this.onDelete, }); @@ -153,6 +159,7 @@ class _SheetBody extends StatelessWidget { final VoidCallback onEditToggle; final VoidCallback onSaveName; final VoidCallback onPlace; + final VoidCallback onCalibrate; final VoidCallback onDelete; @override @@ -226,6 +233,12 @@ class _SheetBody extends StatelessWidget { onPressed: onPlace, ), const SizedBox(height: 12), + OutlinedButton.icon( + icon: const Icon(Icons.radar), + label: const Text('Calibrate'), + onPressed: onCalibrate, + ), + const SizedBox(height: 8), OutlinedButton.icon( icon: const Icon(Icons.bluetooth), label: const Text('Reprovision'), diff --git a/lib/providers.dart b/lib/providers.dart index 53edcb2..43cb415 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'domain/models/server_config.dart'; import 'domain/models/sensor.dart'; +import 'domain/models/calibration.dart'; +import 'features/sensors/calibration_notifier.dart'; import 'domain/models/floor.dart'; import 'domain/models/tag.dart'; import 'domain/models/particle.dart'; @@ -208,3 +210,18 @@ final particleSnapshotProvider = StreamProvider.autoDispose.family((ref, tagId) { return ref.watch(tagRepositoryProvider).watchParticleCloud(tagId); }); + +// --------------------------------------------------------------------------- +// Calibration state - keyed by sensor DB id +// --------------------------------------------------------------------------- + +/// Interactive calibration state machine for a specific sensor. +/// Auto-disposed when no widget is watching (e.g. after the sheet closes). +final calibrationProvider = StateNotifierProvider.autoDispose + .family( + (ref, sensor) => CalibrationNotifier( + sensorId: sensor.id, + sensorDeviceId: sensor.deviceId, + repo: ref.watch(sensorRepositoryProvider), + ), +);