From f37176cce5a0bc30b661032cda07664f735c7dd7 Mon Sep 17 00:00:00 2001 From: dvdrw Date: Sat, 16 May 2026 12:00:55 +0200 Subject: [PATCH] feat: revamp sensor add flow --- assets/konva/app.js | 4 +- .../phoenix_sensor_repository.dart | 18 +- lib/data/repositories/sensor_repository.dart | 5 + lib/data/sources/ble/ble_provisioner.dart | 69 ++- .../localiser/realtime_data_client.dart | 22 + lib/data/sources/localiser/sensor_client.dart | 7 + lib/domain/models/sensor.dart | 4 + .../ble_provision/ble_provision_sheet.dart | 468 ++++++++++++------ lib/features/floorplan/floor_plan_screen.dart | 15 + .../floorplan/widgets/floor_plan_editor.dart | 1 + lib/features/sensors/sensor_list_screen.dart | 40 +- lib/providers.dart | 11 +- 12 files changed, 478 insertions(+), 186 deletions(-) diff --git a/assets/konva/app.js b/assets/konva/app.js index 52645a7..8ec9a4e 100644 --- a/assets/konva/app.js +++ b/assets/konva/app.js @@ -377,6 +377,7 @@ function renderSensors() { if (!existing.isDragging()) existing.position({ x: absX, y: absY }); existing.draggable(mode === 'sensorMove'); existing.findOne('Text')?.text(label); + existing.findOne('.sensor-dot')?.fill(sensor.confirmed ? '#1565C0' : '#9E9E9E'); return; } @@ -394,8 +395,9 @@ function _buildSensorGroup(sensor, absX, absY, label) { // Visible dot — hitFunc expands the touch target without changing appearance. group.add(new Konva.Circle({ + name: 'sensor-dot', radius: 8, - fill: '#1565C0', + fill: sensor.confirmed ? '#1565C0' : '#9E9E9E', stroke: '#ffffff', strokeWidth: 2, hitFunc(ctx, shape) { diff --git a/lib/data/repositories/phoenix_sensor_repository.dart b/lib/data/repositories/phoenix_sensor_repository.dart index 21307a7..5d07ace 100644 --- a/lib/data/repositories/phoenix_sensor_repository.dart +++ b/lib/data/repositories/phoenix_sensor_repository.dart @@ -1,11 +1,20 @@ import '../../domain/models/sensor.dart'; +import '../sources/localiser/realtime_data_client.dart'; import '../sources/localiser/sensor_client.dart'; import 'sensor_repository.dart'; class PhoenixSensorRepository implements SensorRepository { - const PhoenixSensorRepository({required this.client}); + const PhoenixSensorRepository({ + required this.client, + required this.realtime, + }); final SensorClient client; + final RealtimeDataClient realtime; + + @override + Future createSensor(String sensorId, {String? name}) async => + Sensor.fromJson(await client.createSensor(sensorId, name: name)); @override Future> getSensors() async { @@ -35,11 +44,16 @@ class PhoenixSensorRepository implements SensorRepository { @override Future placeSensor(int id, - {required int roomId, required double x, required double y}) async => + {required int roomId, required double x, required double y}) async => Sensor.fromJson( await client.placeSensor(id, {'room_id': roomId, 'x': x, 'y': y})); @override Future unplaceSensor(int id) async => Sensor.fromJson(await client.unplaceSensor(id)); + + @override + Stream> sensorEvents() => realtime + .channelMessages('sensors') + .map((m) => {'event': m.event, ...m.payload}); } diff --git a/lib/data/repositories/sensor_repository.dart b/lib/data/repositories/sensor_repository.dart index e6bdd00..6612b30 100644 --- a/lib/data/repositories/sensor_repository.dart +++ b/lib/data/repositories/sensor_repository.dart @@ -1,6 +1,7 @@ import '../../domain/models/sensor.dart'; abstract class SensorRepository { + Future createSensor(String sensorId, {String? name}); Future> getSensors(); Future> getUnplacedSensors(); Future getSensor(int id); @@ -9,4 +10,8 @@ abstract class SensorRepository { Future placeSensor(int id, {required int roomId, required double x, required double y}); Future unplaceSensor(int id); + + /// Stream of raw SensorsChannel messages. Each map contains an `event` key + /// (`sensor_announced` or `sensor_enrollment_timeout`) plus the payload. + Stream> sensorEvents(); } diff --git a/lib/data/sources/ble/ble_provisioner.dart b/lib/data/sources/ble/ble_provisioner.dart index f388657..7a14262 100644 --- a/lib/data/sources/ble/ble_provisioner.dart +++ b/lib/data/sources/ble/ble_provisioner.dart @@ -31,26 +31,61 @@ class BleProvisioner { BluetoothDevice? _connectedDevice; - Stream scan() async* { - await _requestScanPermissions(); + // Continuously scans for nearby ESP32 sensors, restarting after each + // 15-second window, until the returned stream is cancelled. + Stream scan() { + StreamSubscription>? resultsSub; + StreamSubscription? stateSub; + bool started = false; + late StreamController controller; - await FlutterBluePlus.startScan( - withServices: [Guid(_serviceUuid)], - timeout: const Duration(seconds: 30), + Future startScan() async { + if (controller.isClosed) return; + started = true; + try { + await FlutterBluePlus.startScan( + withServices: [Guid(_serviceUuid)], + ); + } catch (_) {} + } + + controller = StreamController( + onListen: () async { + try { + await _requestScanPermissions(); + } catch (e) { + controller.addError(e); + await controller.close(); + return; + } + + resultsSub = FlutterBluePlus.scanResults.listen((results) { + for (final r in results) { + final name = r.device.platformName; + if (name.startsWith('anchor_') && !controller.isClosed) { + controller.add(BleScanResult( + deviceId: r.device.remoteId.str, + name: name, + rssi: r.rssi, + )); + } + } + }); + + if(!FlutterBluePlus.isScanningNow) { + await startScan(); + } + }, + + onCancel: () { + resultsSub?.cancel(); + stateSub?.cancel(); + FlutterBluePlus.stopScan(); + controller.close(); + }, ); - await for (final results in FlutterBluePlus.scanResults) { - for (final r in results) { - final name = r.device.platformName; - if (name.startsWith('anchor_')) { - yield BleScanResult( - deviceId: r.device.remoteId.str, - name: name, - rssi: r.rssi, - ); - } - } - } + return controller.stream; } Future stopScan() => FlutterBluePlus.stopScan(); diff --git a/lib/data/sources/localiser/realtime_data_client.dart b/lib/data/sources/localiser/realtime_data_client.dart index c36d6cb..13cffc7 100644 --- a/lib/data/sources/localiser/realtime_data_client.dart +++ b/lib/data/sources/localiser/realtime_data_client.dart @@ -56,6 +56,28 @@ class RealtimeDataClient { .map((msg) => msg.payload ?? const {}); } + /// Like [channel], but includes the Phoenix event name in each emission. + Stream<({String event, Map payload})> channelMessages( + String topic, { + Map params = const {}, + }) { + final socket = _socket; + if (socket == null) throw StateError('RealtimeDataClient not connected'); + + final ch = _channels.putIfAbsent( + topic, + () { + final c = socket.addChannel(topic: topic, parameters: params); + c.join(); + return c; + }, + ); + + return ch.messages + .where((msg) => msg.event.value != 'phx_reply') + .map((msg) => (event: msg.event.value, payload: msg.payload ?? const {})); + } + /// 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 9f60645..9eda0fd 100644 --- a/lib/data/sources/localiser/sensor_client.dart +++ b/lib/data/sources/localiser/sensor_client.dart @@ -25,6 +25,13 @@ class SensorClient extends LocaliserdClient { Future> unplaceSensor(int id) async => await deleteBody('/api/sensors/$id/place') as Map; + Future> createSensor(String sensorId, + {String? name}) async => + await post('/api/sensors', { + 'sensor_id': sensorId, + if (name != null) 'name': name, + }) as Map; + Future> startCalibration( int id, double referenceDistance) async => await post('/api/sensors/$id/calibration/start', diff --git a/lib/domain/models/sensor.dart b/lib/domain/models/sensor.dart index a141254..f73705d 100644 --- a/lib/domain/models/sensor.dart +++ b/lib/domain/models/sensor.dart @@ -2,6 +2,7 @@ class Sensor { const Sensor({ required this.id, required this.sensorId, + required this.confirmed, this.name, this.roomId, this.x, @@ -10,6 +11,7 @@ class Sensor { final int id; final String sensorId; // BLE MAC / provisioning device ID + final bool confirmed; // true once seen on MQTT final String? name; // human-readable label final int? roomId; final double? x; // room relative @@ -21,6 +23,7 @@ class Sensor { factory Sensor.fromJson(Map json) => Sensor( id: json['id'] as int, sensorId: json['sensor_id'] as String, + confirmed: json['confirmed'] as bool, name: json['name'] as String?, roomId: json['room_id'] as int?, x: (json['x'] as num?)?.toDouble(), @@ -37,6 +40,7 @@ class Sensor { Sensor( id: id, sensorId: sensorId, + confirmed: confirmed, name: name ?? this.name, roomId: roomId ?? this.roomId, x: x ?? this.x, diff --git a/lib/features/ble_provision/ble_provision_sheet.dart b/lib/features/ble_provision/ble_provision_sheet.dart index 24d8e98..66b73de 100644 --- a/lib/features/ble_provision/ble_provision_sheet.dart +++ b/lib/features/ble_provision/ble_provision_sheet.dart @@ -5,10 +5,10 @@ import 'package:go_router/go_router.dart'; import '../../data/sources/ble/ble_provisioner.dart'; import '../../providers.dart'; -enum _Step { provision, done } +enum _Step { scan, configure, done } // Shared bottom sheet used by onboarding and the main sensor screens. -// Flow: scan → select device → enter WiFi credentials → provision → place on map. +// Flow: scan -> select device (slides to configure) -> provision -> done. class BleProvisionSheet extends ConsumerStatefulWidget { const BleProvisionSheet({super.key}); @@ -20,10 +20,12 @@ class _BleProvisionSheetState extends ConsumerState { final _provisioner = BleProvisioner(); final _ssidController = TextEditingController(); final _wifiPasswordController = TextEditingController(); + late final PageController _pageController; late Stream _scanStream; + int _scanGeneration = 0; - _Step _step = _Step.provision; + _Step _step = _Step.scan; BleScanResult? _selected; bool _provisioning = false; String? _error; @@ -31,6 +33,7 @@ class _BleProvisionSheetState extends ConsumerState { @override void initState() { super.initState(); + _pageController = PageController(); _scanStream = _provisioner.scan(); } @@ -39,6 +42,7 @@ class _BleProvisionSheetState extends ConsumerState { _provisioner.dispose(); _ssidController.dispose(); _wifiPasswordController.dispose(); + _pageController.dispose(); super.dispose(); } @@ -57,7 +61,17 @@ class _BleProvisionSheetState extends ConsumerState { mqttHost: mqttBroker?.host, mqttPort: mqttBroker?.port, ); - if (mounted) setState(() => _step = _Step.done); + await ref + .read(sensorRepositoryProvider) + .createSensor(_selected!.name, name: _selected!.name); + if (mounted) { + setState(() => _step = _Step.done); + _pageController.animateToPage( + 2, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } } catch (e) { if (mounted) setState(() => _error = e.toString()); } finally { @@ -65,15 +79,46 @@ class _BleProvisionSheetState extends ConsumerState { } } + void _selectDevice(BleScanResult r) { + setState(() { + _selected = r; + _step = _Step.configure; + }); + _pageController.animateToPage( + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + void _goBack() { + setState(() { + _step = _Step.scan; + _selected = null; + _error = null; + _scanGeneration++; + }); + _pageController.animateToPage( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + void _reset() { setState(() { - _step = _Step.provision; + _step = _Step.scan; _selected = null; _error = null; _ssidController.clear(); _wifiPasswordController.clear(); - _scanStream = _provisioner.scan(); + _scanGeneration++; }); + _pageController.animateToPage( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); } @override @@ -83,44 +128,185 @@ class _BleProvisionSheetState extends ConsumerState { initialChildSize: 0.6, maxChildSize: 0.9, builder: (context, scrollController) => Padding( - padding: EdgeInsets.fromLTRB( - 24, - 16, - 24, - MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: _step == _Step.done - ? _DoneStep( - onGoToFloorPlan: () { - final router = GoRouter.of(context); - Navigator.of(context).pop(); - router.go('/floorplan'); - }, - onAddAnother: _reset, - onDone: () => Navigator.of(context).pop(), - ) - : _ProvisionStep( - scrollController: scrollController, - scanStream: _scanStream, - selected: _selected, - onSelected: (r) => setState(() => _selected = r), - ssidController: _ssidController, - wifiPasswordController: _wifiPasswordController, - provisioning: _provisioning, - error: _error, - onProvision: _provision, + padding: const EdgeInsets.symmetric(horizontal: 0), + child: Column( + children: [ + if (_step != _Step.done) ...[ + Padding( + padding: const .fromLTRB(12, 12, 12, 0), + child: Column( + children: [ + Padding( + padding: const .fromLTRB(0, 0, 12, 4), + child: Row( + children: [ + if (_step == _Step.configure) + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _goBack, + ) + else + const SizedBox(width: 0, height: 48), + Expanded( + child: Padding( + padding: const .only(left: 12), + child: Text( + _step == _Step.scan + ? 'Add sensor' + : (_selected?.name ?? 'Configure'), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + ], + ), + ), + + const Divider(height: 0), + ], + ), ), + ], + Expanded( + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: + [ + _ScanPage( + scrollController: scrollController, + scanStream: _scanStream, + generation: _scanGeneration, + selected: _selected, + onSelected: _selectDevice, + ), + _ConfigurePage( + ssidController: _ssidController, + wifiPasswordController: _wifiPasswordController, + provisioning: _provisioning, + error: _error, + onProvision: _provision, + ), + _DoneStep( + onGoToFloorPlan: () { + final router = GoRouter.of(context); + Navigator.of(context).pop(); + router.go('/floorplan'); + }, + onAddAnother: _reset, + onDone: () => Navigator.of(context).pop(), + ), + ] + .map( + (page) => Padding( + padding: const .symmetric(horizontal: 24), + child: page, + ), + ) + .toList(), + ), + ), + ], + ), ), ); } } -class _ProvisionStep extends StatefulWidget { - const _ProvisionStep({ +class _ScanPage extends StatefulWidget { + const _ScanPage({ required this.scrollController, required this.scanStream, + required this.generation, required this.selected, required this.onSelected, + }); + + final ScrollController scrollController; + final Stream scanStream; + final int generation; + final BleScanResult? selected; + final ValueChanged onSelected; + + @override + State<_ScanPage> createState() => _ScanPageState(); +} + +class _ScanPageState extends State<_ScanPage> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + final _discovered = {}; + + @override + void didUpdateWidget(_ScanPage old) { + super.didUpdateWidget(old); + if (old.generation != widget.generation || + old.scanStream != widget.scanStream) { + _discovered.clear(); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return StreamBuilder( + stream: widget.scanStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + _discovered[snapshot.data!.deviceId] = snapshot.data!; + } + + return ListView( + controller: widget.scrollController, + padding: const EdgeInsets.only(top: 16, bottom: 24), + children: [ + Row( + children: [ + Text( + 'Nearby sensor devices', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(width: 12), + const SizedBox.square( + dimension: 12, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + ], + ), + const SizedBox(height: 4), + if (_discovered.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + 'No devices found yet…', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + else + ..._discovered.values.map((r) { + final selected = widget.selected?.deviceId == r.deviceId; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.sensors), + title: Text(r.name), + subtitle: Text('${r.rssi} dBm'), + selected: selected, + onTap: () => widget.onSelected(r), + ); + }), + ], + ); + }, + ); + } +} + +class _ConfigurePage extends StatefulWidget { + const _ConfigurePage({ required this.ssidController, required this.wifiPasswordController, required this.provisioning, @@ -128,10 +314,6 @@ class _ProvisionStep extends StatefulWidget { required this.onProvision, }); - final ScrollController scrollController; - final Stream scanStream; - final BleScanResult? selected; - final ValueChanged onSelected; final TextEditingController ssidController; final TextEditingController wifiPasswordController; final bool provisioning; @@ -139,107 +321,83 @@ class _ProvisionStep extends StatefulWidget { final VoidCallback onProvision; @override - State<_ProvisionStep> createState() => _ProvisionStepState(); + State<_ConfigurePage> createState() => _ConfigurePageState(); } -class _ProvisionStepState extends State<_ProvisionStep> { - final _discovered = {}; +class _ConfigurePageState extends State<_ConfigurePage> { + bool _obscurePassword = true; @override Widget build(BuildContext context) { - return ListView( - controller: widget.scrollController, - children: [ - Text('Add sensor', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 16), + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + // scrollPadding ensures the field scrolls fully clear of the keyboard. + final fieldScrollPadding = EdgeInsets.only(bottom: bottomInset + 24); - Text('Nearby ESP32 devices', - style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - - StreamBuilder( - stream: widget.scanStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - final r = snapshot.data!; - _discovered[r.deviceId] = r; - } - if (_discovered.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Row( - children: [ - SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2)), - SizedBox(width: 12), - Text('Scanning…'), - ], - ), - ); - } - return Column( - children: _discovered.values.map((r) { - final selected = widget.selected?.deviceId == r.deviceId; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.sensors), - title: Text(r.name), - subtitle: Text('${r.rssi} dBm'), - trailing: selected - ? const Icon(Icons.check_circle, - color: Colors.green) - : null, - selected: selected, - onTap: () => widget.onSelected(r), - ); - }).toList(), - ); - }, - ), - - const SizedBox(height: 24), - - if (widget.selected != null) ...[ - Text('Selected: ${widget.selected!.name}'), - const SizedBox(height: 16), + return SingleChildScrollView( + padding: EdgeInsets.only(top: 16, bottom: bottomInset + 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ TextField( controller: widget.ssidController, + scrollPadding: fieldScrollPadding, decoration: const InputDecoration( labelText: 'WiFi SSID', border: OutlineInputBorder(), ), + textInputAction: .next, ), const SizedBox(height: 12), TextField( controller: widget.wifiPasswordController, - decoration: const InputDecoration( - labelText: 'WiFi password', - border: OutlineInputBorder(), + scrollPadding: fieldScrollPadding, + decoration: InputDecoration( + labelText: 'WiFi Password', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), ), - obscureText: true, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + onSubmitted: (_) { + if (widget.ssidController.text.trim().isNotEmpty) { + widget.onProvision(); + } + }, ), const SizedBox(height: 16), - ], - - if (widget.error != null) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(widget.error!, - style: - TextStyle(color: Theme.of(context).colorScheme.error)), + if (widget.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + widget.error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ListenableBuilder( + listenable: widget.ssidController, + builder: (context, _) { + final canProvision = widget.ssidController.text.trim().isNotEmpty; + return FilledButton( + onPressed: (widget.provisioning || !canProvision) + ? null + : widget.onProvision, + child: widget.provisioning + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Provision & add'), + ); + }, ), - - FilledButton( - onPressed: - (widget.selected == null || widget.provisioning) ? null : widget.onProvision, - child: widget.provisioning - ? const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Provision & add'), - ), - ], + ], + ), ); } } @@ -257,42 +415,42 @@ class _DoneStep extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.check_circle_outline, size: 56), - const SizedBox(height: 16), - Text( - 'Sensor provisioned!', - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'The sensor will appear in the list once it connects to the network. ' - 'Open the floor plan to place it.', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - FilledButton.icon( - icon: const Icon(Icons.map_outlined), - label: const Text('Place on floor plan'), - onPressed: onGoToFloorPlan, - ), - const SizedBox(height: 12), - OutlinedButton.icon( - icon: const Icon(Icons.add), - label: const Text('Add another sensor'), - onPressed: onAddAnother, - ), - const SizedBox(height: 8), - TextButton( - onPressed: onDone, - child: const Text('Done'), - ), - ], + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.check_circle_outline, size: 56), + const SizedBox(height: 16), + Text( + 'Sensor provisioned!', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'The sensor will appear in the list once it connects to the network. ' + 'Open the floor plan to place it.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton.icon( + icon: const Icon(Icons.map_outlined), + label: const Text('Place on floor plan'), + onPressed: onGoToFloorPlan, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + icon: const Icon(Icons.add), + label: const Text('Add another sensor'), + onPressed: onAddAnother, + ), + const SizedBox(height: 8), + TextButton(onPressed: onDone, child: const Text('Done')), + ], + ), ); } } diff --git a/lib/features/floorplan/floor_plan_screen.dart b/lib/features/floorplan/floor_plan_screen.dart index 7930dea..410894f 100644 --- a/lib/features/floorplan/floor_plan_screen.dart +++ b/lib/features/floorplan/floor_plan_screen.dart @@ -94,6 +94,21 @@ class _FloorPlanScreenState extends ConsumerState { ref.listen(sensorsProvider, (_, next) { next.whenData((s) => _editorKey.currentState?.loadSensors(s)); }); + ref.listen(sensorsChannelProvider, (_, next) { + next.whenData((msg) { + switch (msg['event'] as String?) { + case 'sensor_announced': + ref.invalidate(sensorsProvider); + case 'sensor_enrollment_timeout': + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sensor did not come online in time. ' + 'Check WiFi credentials and try again.'), + ), + ); + } + }); + }); ref.listen(tagPositionsProvider, (_, next) { next.whenData((t) => _editorKey.currentState?.updateTags(t)); }); diff --git a/lib/features/floorplan/widgets/floor_plan_editor.dart b/lib/features/floorplan/widgets/floor_plan_editor.dart index 993e1c5..ba0cd6a 100644 --- a/lib/features/floorplan/widgets/floor_plan_editor.dart +++ b/lib/features/floorplan/widgets/floor_plan_editor.dart @@ -149,6 +149,7 @@ class FloorPlanEditorState extends State { 'floor_x': s.x, 'floor_y': s.y, 'room_id': s.roomId, + 'confirmed': s.confirmed, }, ) .toList(), diff --git a/lib/features/sensors/sensor_list_screen.dart b/lib/features/sensors/sensor_list_screen.dart index 4d0dfea..9f4d517 100644 --- a/lib/features/sensors/sensor_list_screen.dart +++ b/lib/features/sensors/sensor_list_screen.dart @@ -13,6 +13,22 @@ class SensorListScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final sensors = ref.watch(sensorsProvider); + ref.listen(sensorsChannelProvider, (_, next) { + next.whenData((msg) { + switch (msg['event'] as String?) { + case 'sensor_announced': + ref.invalidate(sensorsProvider); + case 'sensor_enrollment_timeout': + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sensor did not come online in time. ' + 'Check WiFi credentials and try again.'), + ), + ); + } + }); + }); + return Scaffold( appBar: AppBar(title: const Text('Sensors')), body: Column( @@ -35,16 +51,12 @@ class SensorListScreen extends ConsumerWidget { if (unplaced.isNotEmpty) ...[ _SectionHeader( label: 'Unplaced', count: unplaced.length), - ...unplaced.map((s) => _SensorTile( - sensor: s, - )), + ...unplaced.map((s) => _SensorTile(sensor: s)), ], if (placed.isNotEmpty) ...[ _SectionHeader( label: 'Placed', count: placed.length), - ...placed.map((s) => _SensorTile( - sensor: s, - )), + ...placed.map((s) => _SensorTile(sensor: s)), ], ], ); @@ -87,22 +99,30 @@ class _SectionHeader extends StatelessWidget { } class _SensorTile extends StatelessWidget { - const _SensorTile({required this.sensor, this.isSelected = false}); + const _SensorTile({required this.sensor}); final Sensor sensor; - final bool isSelected; @override Widget build(BuildContext context) { return ListTile( - selected: isSelected, leading: Icon(sensor.isPlaced ? Icons.sensors : Icons.sensors_off_outlined), title: Text(sensor.displayName), subtitle: Text( sensor.isPlaced ? 'Placed' : 'Not placed on floor plan'), - trailing: const Icon(Icons.chevron_right), + textColor: sensor.confirmed ? null : Colors.grey, + trailing: sensor.confirmed + ? const Icon(Icons.chevron_right) + : Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.access_time, color: Colors.amber, size: 18), + SizedBox(width: 4), + Icon(Icons.chevron_right), + ], + ), onTap: () => showSensorDetailSheet(context, sensor.id), ); } diff --git a/lib/providers.dart b/lib/providers.dart index 8b2f24d..61ffe87 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -102,7 +102,10 @@ final onboardingRepositoryProvider = Provider((ref) { }); final sensorRepositoryProvider = Provider((ref) { - return PhoenixSensorRepository(client: ref.watch(sensorClientProvider)); + return PhoenixSensorRepository( + client: ref.watch(sensorClientProvider), + realtime: _requireRealtime(ref), + ); }); final tagRepositoryProvider = Provider((ref) { @@ -157,6 +160,12 @@ final floorPlanModeProvider = // Live data streams // --------------------------------------------------------------------------- +/// Raw events pushed by the server's SensorsChannel. +/// Each map has an `event` key: `sensor_announced` or `sensor_enrollment_timeout`. +final sensorsChannelProvider = StreamProvider>((ref) { + return ref.watch(sensorRepositoryProvider).sensorEvents(); +}); + final tagPositionsProvider = StreamProvider>((ref) { final repo = ref.watch(tagRepositoryProvider); return repo.watchPositions();