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(); } }