diff --git a/lib/data/repositories/phoenix_sensor_repository.dart b/lib/data/repositories/phoenix_sensor_repository.dart index 443ffbc..7ea8a53 100644 --- a/lib/data/repositories/phoenix_sensor_repository.dart +++ b/lib/data/repositories/phoenix_sensor_repository.dart @@ -80,6 +80,12 @@ class PhoenixSensorRepository implements SensorRepository { ); } + @override + Future setCalibrationTag(int id, String tagId) async { + final json = await client.setCalibrationTag(id, tagId); + return json['samples_needed'] as int; + } + @override Future cancelCalibration(int id) => client.cancelCalibration(id); diff --git a/lib/data/repositories/sensor_repository.dart b/lib/data/repositories/sensor_repository.dart index ba2e61c..be8151d 100644 --- a/lib/data/repositories/sensor_repository.dart +++ b/lib/data/repositories/sensor_repository.dart @@ -32,6 +32,9 @@ abstract class SensorRepository { /// result. Returns the fitted (rssiRef, pathLossExp) pair. Future<({double rssiRef, double pathLossExp})> finishCalibration(int id); + /// Commits a specific tag for calibration stages. Returns samples_needed. + Future setCalibrationTag(int id, String tagId); + /// Aborts calibration and discards all accumulated stage data. Future cancelCalibration(int id); diff --git a/lib/data/sources/localiser/sensor_client.dart b/lib/data/sources/localiser/sensor_client.dart index 835e76c..46808c9 100644 --- a/lib/data/sources/localiser/sensor_client.dart +++ b/lib/data/sources/localiser/sensor_client.dart @@ -52,6 +52,12 @@ class SensorClient extends LocaliserdClient { '/api/sensors/$id/calibration/finish', ) as Map; + Future> setCalibrationTag(int id, String tagId) async => + await post('/api/sensors/$id/calibration/tag', { + 'tag_id': tagId, + }) + as Map; + Future cancelCalibration(int id) => delete('/api/sensors/$id/calibration'); diff --git a/lib/domain/models/calibration.dart b/lib/domain/models/calibration.dart index 6ecaa08..7f2309d 100644 --- a/lib/domain/models/calibration.dart +++ b/lib/domain/models/calibration.dart @@ -1,4 +1,4 @@ -enum CalibrationPhase { idle, ready, collecting, done, error } +enum CalibrationPhase { idle, selectingTag, ready, collecting, done, error } const calibrationDistances = [0.5, 1.0, 2.0, 3.0]; @@ -14,6 +14,8 @@ class CalibrationState { this.avgRssi, this.resultRssiRef, this.resultPathLossExp, + this.tagRssiReadings = const {}, + this.selectedTagId, this.error, }); @@ -28,6 +30,10 @@ class CalibrationState { final double? avgRssi; final double? resultRssiRef; final double? resultPathLossExp; + /// Latest scan RSSI per tag device ID, updated during selectingTag phase. + final Map tagRssiReadings; + /// The tag device ID committed for this calibration session. + final String? selectedTagId; final String? error; bool get canFinish => @@ -47,10 +53,14 @@ class CalibrationState { double? avgRssi, double? resultRssiRef, double? resultPathLossExp, + Map? tagRssiReadings, + String? selectedTagId, String? error, bool clearSelectedDistance = false, bool clearLatestRssi = false, bool clearAvgRssi = false, + bool clearTagRssiReadings = false, + bool clearSelectedTagId = false, bool clearError = false, }) => CalibrationState( @@ -66,6 +76,11 @@ class CalibrationState { avgRssi: clearAvgRssi ? null : avgRssi ?? this.avgRssi, resultRssiRef: resultRssiRef ?? this.resultRssiRef, resultPathLossExp: resultPathLossExp ?? this.resultPathLossExp, + tagRssiReadings: clearTagRssiReadings + ? const {} + : tagRssiReadings ?? this.tagRssiReadings, + selectedTagId: + clearSelectedTagId ? null : selectedTagId ?? this.selectedTagId, error: clearError ? null : error ?? this.error, ); } diff --git a/lib/features/sensors/calibration_notifier.dart b/lib/features/sensors/calibration_notifier.dart index f14aa7e..7367f0d 100644 --- a/lib/features/sensors/calibration_notifier.dart +++ b/lib/features/sensors/calibration_notifier.dart @@ -25,22 +25,22 @@ class CalibrationNotifier extends StateNotifier { /// 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, + phase: CalibrationPhase.selectingTag, samplesNeeded: samplesNeeded, samplesCollected: 0, - selectedDistance: firstUncompleted, + selectedDistance: calibrationDistances.first, completedDistances: {}, waveform: [], clearLatestRssi: true, + clearTagRssiReadings: true, + clearSelectedTagId: true, ); } catch (e) { _sub?.cancel(); @@ -52,6 +52,28 @@ class CalibrationNotifier extends StateNotifier { } } + void selectTag(String tagId) { + if (state.phase != CalibrationPhase.selectingTag) return; + state = state.copyWith(selectedTagId: tagId); + } + + Future commitTag() async { + final tagId = state.selectedTagId; + if (tagId == null || state.phase != CalibrationPhase.selectingTag) return; + try { + final samplesNeeded = await repo.setCalibrationTag(sensorId, tagId); + state = state.copyWith( + phase: CalibrationPhase.ready, + samplesNeeded: samplesNeeded, + ); + } catch (e) { + 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; @@ -111,6 +133,11 @@ class CalibrationNotifier extends StateNotifier { void _onEvent(({String event, Map payload}) msg) { switch (msg.event) { + case 'scan_reading': + _handleScanReading(msg.payload); + case 'tag_set': + final tagId = msg.payload['tag_id'] as String; + state = state.copyWith(selectedTagId: tagId); case 'reading': _handleReading(msg.payload); case 'stage_complete': @@ -122,6 +149,14 @@ class CalibrationNotifier extends StateNotifier { } } + void _handleScanReading(Map payload) { + final tagId = payload['tag_id'] as String; + final rssi = (payload['rssi'] as num).toDouble(); + state = state.copyWith( + tagRssiReadings: {...state.tagRssiReadings, tagId: rssi}, + ); + } + void _handleReading(Map payload) { final rssi = (payload['rssi'] as num).toDouble(); final progress = payload['stage_progress'] as Map; diff --git a/lib/features/sensors/calibration_sheet.dart b/lib/features/sensors/calibration_sheet.dart index cf26966..1657dc8 100644 --- a/lib/features/sensors/calibration_sheet.dart +++ b/lib/features/sensors/calibration_sheet.dart @@ -5,6 +5,7 @@ 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( @@ -71,11 +72,22 @@ class _CalibrationSheetState extends ConsumerState { } } + 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( - 2, + 3, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); @@ -115,6 +127,11 @@ class _CalibrationSheetState extends ConsumerState { onBegin: _begin, onCancel: () => _cancelAndClose(context), ), + _TagSelectionPage( + state: state, + sensorKey: _key, + onNext: _commitTag, + ), _CollectingPage( state: state, sensorKey: _key, @@ -214,7 +231,170 @@ class _IntroPage extends StatelessWidget { } // --------------------------------------------------------------------------- -// Page 2 — Collecting +// 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 { @@ -772,12 +952,14 @@ class _AsyncButton extends StatefulWidget { required this.label, this.icon, this.compact = false, + this.enabled = true, }); final Future Function() onPressed; final String label; final IconData? icon; final bool compact; + final bool enabled; @override State<_AsyncButton> createState() => _AsyncButtonState(); @@ -806,12 +988,14 @@ class _AsyncButtonState extends State<_AsyncButton> { Icon(widget.icon, size: 18), ], ) - : Text(widget.label); + : Text(widget.label, style: TextStyle(fontSize: 36, color: Theme.of(context).colorScheme.primary)); + + final canPress = !_loading && widget.enabled; if (widget.compact) { return TextButton( - style: TextButton.styleFrom(shape: shape, textStyle: const .new(fontSize: 32)), - onPressed: _loading ? null : _run, + style: TextButton.styleFrom(shape: shape, textStyle: const TextStyle(fontSize: 36, color: Colors.black)), + onPressed: canPress ? _run : null, child: child, ); } @@ -821,7 +1005,7 @@ class _AsyncButtonState extends State<_AsyncButton> { shape: shape, minimumSize: const Size.fromHeight(48), ), - onPressed: _loading ? null : _run, + onPressed: canPress ? _run : null, child: child, ); }