From 52605f1390c83bd6bfd208e775605d05edb7c2b2 Mon Sep 17 00:00:00 2001 From: dvdrw Date: Thu, 14 May 2026 17:22:26 +0200 Subject: [PATCH] feat: move sensor details to sheet, implement placement on floor plan --- android/app/src/main/AndroidManifest.xml | 1 + assets/konva/app.js | 107 ++---- assets/konva/index.html | 5 - lib/domain/models/sensor.dart | 2 +- lib/features/floorplan/floor_plan_screen.dart | 328 ++++++++++++------ .../floorplan/widgets/floor_plan_editor.dart | 22 ++ lib/features/sensors/sensor_detail_sheet.dart | 246 +++++++++++++ lib/features/sensors/sensor_list_screen.dart | 101 ++++-- lib/providers.dart | 4 + lib/router.dart | 16 - pubspec.lock | 40 +++ pubspec.yaml | 1 + 12 files changed, 655 insertions(+), 218 deletions(-) create mode 100644 lib/features/sensors/sensor_detail_sheet.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 01a6c8b..0fe0ecd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + diff --git a/assets/konva/app.js b/assets/konva/app.js index c3e3813..806ccfb 100644 --- a/assets/konva/app.js +++ b/assets/konva/app.js @@ -1,7 +1,4 @@ -/* globals: Konva, preact, preactHooks, htm */ -const { h, render } = preact; -const { useState } = preactHooks; -const html = htm.bind(h); +/* globals: Konva */ // --------------------------------------------------------------------------- // Constants & mutable state @@ -20,9 +17,6 @@ let _dragging = false; // true while any room group is being dragged let _dimRoom = null; let _dimGroup = null; -// Exposed so the Preact overlay can update its tooltip imperatively. -let _setTooltip = (_v) => {}; - // --------------------------------------------------------------------------- // Stage & layers // --------------------------------------------------------------------------- @@ -365,27 +359,23 @@ function renderSensors() { listening: false, })); - group.on('click tap', () => _onSensorTap(sensor, group, label)); - group.on('dragend', () => _onSensorDragEnd(sensor, group, room)); + let pressTimer = null; + group.on('mousedown touchstart', () => { + pressTimer = setTimeout(() => { + notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) }); + pressTimer = null; + }, 500); + }); + group.on('mouseup touchend touchcancel dragstart', () => { + clearTimeout(pressTimer); + pressTimer = null; + }); + group.on('dragend', () => _onSensorDragEnd(sensor, group, room)); sensorsLayer.add(group); }); sensorsLayer.batchDraw(); } -function _onSensorTap(sensor, group, label) { - if (mode === 'view') { - const scale = stage.scaleX(); - _setTooltip({ - id: String(sensor.id), - name: label, - x: group.x() * scale + stage.x(), - y: group.y() * scale + stage.y(), - }); - } else { - notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) }); - } -} - function _onSensorDragEnd(sensor, group, originalRoom) { const newAbsX = group.x() / PPM; const newAbsY = group.y() / PPM; @@ -461,57 +451,9 @@ function _fitToRooms() { }); } -// --------------------------------------------------------------------------- -// Preact overlay — sensor tooltip -// --------------------------------------------------------------------------- - -function Tooltip({ id, name, x, y, onClose }) { - return html` -
-
- ${name} -
-
- - -
-
- `; -} - -function App() { - const [tooltip, setTip] = useState(null); - _setTooltip = setTip; - return tooltip - ? html`<${Tooltip} ...${tooltip} onClose=${() => setTip(null)} />` - : null; -} - -render(html`<${App} />`, document.getElementById('overlay')); - -// Dismiss tooltip and dimensions when tapping the stage background. +// Dismiss dimensions when tapping the stage background. stage.on('click tap', (e) => { if (e.target === stage) { - _setTooltip(null); _clearDimensions(); notifyFlutter({ type: 'selectionCleared' }); } @@ -572,7 +514,6 @@ window.companion = { tagsLayer.visible(!edit); particlesLayer.visible(!edit); _clearDimensions(); - _setTooltip(null); stage.batchDraw(); }, @@ -602,4 +543,24 @@ window.companion = { fitToRooms() { _fitToRooms(); }, + + getPositionAtCenter() { + const s = stage.scaleX(); + const cx = (stage.width() / 2 - stage.x()) / s / PPM; + const cy = (stage.height() / 2 - stage.y()) / s / PPM; + let result = { type: 'positionAtCenter', roomId: null }; + for (const room of rooms) { + if (cx >= room.x && cx <= room.x + room.width && + cy >= room.y && cy <= room.y + room.height) { + result = { + type: 'positionAtCenter', + roomId: room.id, + x: cx - room.x, + y: cy - room.y, + }; + break; + } + } + FlutterBridge.postMessage(JSON.stringify(result)); + }, }; diff --git a/assets/konva/index.html b/assets/konva/index.html index a5b51ea..041da09 100644 --- a/assets/konva/index.html +++ b/assets/konva/index.html @@ -7,16 +7,11 @@ * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; background: #f5f5f5; } #canvas-container { position: fixed; inset: 0; } - #overlay { position: fixed; inset: 0; pointer-events: none; }
-
- - - diff --git a/lib/domain/models/sensor.dart b/lib/domain/models/sensor.dart index 055fd81..a141254 100644 --- a/lib/domain/models/sensor.dart +++ b/lib/domain/models/sensor.dart @@ -15,7 +15,7 @@ class Sensor { final double? x; // room relative final double? y; // room relative - bool get isPlaced => roomId != null; + bool get isPlaced => roomId != null && x != null && y != null; String get displayName => name ?? sensorId; factory Sensor.fromJson(Map json) => Sensor( diff --git a/lib/features/floorplan/floor_plan_screen.dart b/lib/features/floorplan/floor_plan_screen.dart index fb8a97e..121ba05 100644 --- a/lib/features/floorplan/floor_plan_screen.dart +++ b/lib/features/floorplan/floor_plan_screen.dart @@ -6,6 +6,7 @@ import '../../domain/models/floor_plan_mode.dart'; import '../../domain/models/sensor.dart'; import '../../providers.dart'; import '../ble_provision/ble_provision_sheet.dart'; +import '../sensors/sensor_detail_sheet.dart'; import 'widgets/floor_plan_editor.dart'; import 'widgets/room_edit_sheet.dart'; @@ -34,11 +35,39 @@ class _FloorPlanScreenState extends ConsumerState { .then((sensors) => _editorKey.currentState?.loadSensors(sensors)); } + Future _confirmPlacement(Sensor sensor) async { + final result = + await _editorKey.currentState?.getPositionAtCenter(); + if (!mounted) return; + if (result == null || result.roomId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Move the map so the dot is over a room'), + ), + ); + return; + } + await ref.read(sensorRepositoryProvider).placeSensor( + sensor.id, + roomId: result.roomId!, + x: result.x!, + y: result.y!, + ); + ref.read(sensorPlacementProvider.notifier).state = null; + ref.invalidate(sensorsProvider); + ref.invalidate(sensorProvider(sensor.id)); + } + + void _cancelPlacement() { + ref.read(sensorPlacementProvider.notifier).state = null; + } + @override Widget build(BuildContext context) { final mode = ref.watch(floorPlanModeProvider); final roomsAsync = ref.watch(roomsProvider); final sensorsAsync = ref.watch(sensorsProvider); + final placingSensor = ref.watch(sensorPlacementProvider); ref.listen(roomsProvider, (_, next) { next.whenData((r) => _editorKey.currentState?.loadFloorPlan(r)); @@ -61,104 +90,144 @@ class _FloorPlanScreenState extends ConsumerState { .toList() ?? []; - return Scaffold( - appBar: AppBar( - title: const Text('Floor Plan'), - actions: [ - IconButton( - tooltip: mode == FloorPlanMode.edit ? 'View mode' : 'Edit mode', - icon: Icon( - mode == FloorPlanMode.edit ? Icons.visibility : Icons.edit, - ), - onPressed: () { - final next = mode == FloorPlanMode.edit - ? FloorPlanMode.view - : FloorPlanMode.edit; - ref.read(floorPlanModeProvider.notifier).state = next; - _editorKey.currentState?.setMode(next); - }, - ), - ], - ), - body: Column( - children: [ - Expanded( - child: Stack( - children: [ - FloorPlanEditor( - key: _editorKey, - mode: mode, - onSensorTapped: (id) { - ref.read(selectedSensorIdProvider.notifier).state = id; - context.push('/sensors/$id'); - }, - onSensorMoved: (sensorId, roomId, x, y) async { - await ref.read(sensorRepositoryProvider).placeSensor( - int.parse(sensorId), - roomId: roomId, - x: x, - y: y, - ); - ref.invalidate(sensorsProvider); - }, - onRoomsUpdated: (roomUpdates) async { - final floor = ref.read(floorProvider).valueOrNull; - if (floor == null) return; - final repo = ref.read(floorRepositoryProvider); - for (final r in roomUpdates) { - await repo.updateRoom(floor.id, r.id, x: r.x, y: r.y); - } - // Do NOT invalidate roomsProvider here: the JS canvas is - // already showing the correct positions, and re-fetching - // would replace rooms[] with a fresh array, orphaning the - // drag-closure references and causing snap-back. - }, - onRoomAdded: (x, y) => _showAddRoomDialog(x, y), - onEditRoomTapped: _showRoomEditSheet, - onEditSensorTapped: (sensorId) => - context.push('/floorplan/sensors/$sensorId'), - onAddSensorTapped: () => showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: (_) => const BleProvisionSheet(), - ).then((_) { - ref.invalidate(sensorsProvider); - }), + return PopScope( + // While placing, intercept back: cancel placement and go to sensors list. + canPop: placingSensor == null, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && placingSensor != null) { + _cancelPlacement(); + context.go('/sensors'); + } + }, + child: Scaffold( + appBar: AppBar( + title: const Text('Floor Plan'), + actions: [ + if (placingSensor == null) + IconButton( + tooltip: + mode == FloorPlanMode.edit ? 'View mode' : 'Edit mode', + icon: Icon( + mode == FloorPlanMode.edit + ? Icons.visibility + : Icons.edit, ), - if (roomsAsync.valueOrNull?.isEmpty ?? false) - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.map_outlined, - size: 64, color: Colors.grey.shade400), - const SizedBox(height: 12), - Text( - 'No rooms yet', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Colors.grey.shade600), - ), - const SizedBox(height: 4), - Text( - 'Switch to edit mode and tap + to add a room', - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Colors.grey.shade500), - textAlign: TextAlign.center, - ), - ], - ), + onPressed: () { + final next = mode == FloorPlanMode.edit + ? FloorPlanMode.view + : FloorPlanMode.edit; + ref.read(floorPlanModeProvider.notifier).state = next; + _editorKey.currentState?.setMode(next); + }, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: Stack( + children: [ + FloorPlanEditor( + key: _editorKey, + mode: mode, + onSensorTapped: (id) { + ref.read(selectedSensorIdProvider.notifier).state = + id; + showSensorDetailSheet(context, int.parse(id)); + }, + onSensorMoved: (sensorId, roomId, x, y) async { + final id = int.parse(sensorId); + await ref + .read(sensorRepositoryProvider) + .placeSensor( + id, + roomId: roomId, + x: x, + y: y, + ); + ref.invalidate(sensorsProvider); + ref.invalidate(sensorProvider(id)); + }, + onRoomsUpdated: (roomUpdates) async { + final floor = ref.read(floorProvider).valueOrNull; + if (floor == null) return; + final repo = ref.read(floorRepositoryProvider); + for (final r in roomUpdates) { + await repo.updateRoom(floor.id, r.id, + x: r.x, y: r.y); + } + // Do NOT invalidate roomsProvider here: the JS canvas is + // already showing the correct positions, and re-fetching + // would replace rooms[] with a fresh array, orphaning the + // drag-closure references and causing snap-back. + }, + onRoomAdded: (x, y) => _showAddRoomDialog(x, y), + onEditRoomTapped: _showRoomEditSheet, + onEditSensorTapped: (id) => + showSensorDetailSheet(context, int.parse(id)), + onAddSensorTapped: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (_) => const BleProvisionSheet(), + ).then((_) { + ref.invalidate(sensorsProvider); + }), ), - ], + if (roomsAsync.valueOrNull?.isEmpty ?? false) + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.map_outlined, + size: 64, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text( + 'No rooms yet', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: Colors.grey.shade600), + ), + const SizedBox(height: 4), + Text( + 'Switch to edit mode and tap + to add a room', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Colors.grey.shade500), + textAlign: TextAlign.center, + ), + ], + ), + ), + // Placement mode overlay + if (placingSensor != null) ...[ + const Center(child: _PlacementDot()), + Positioned( + top: 16, + left: 16, + right: 16, + child: _PlacementCard( + sensor: placingSensor, + onPlace: () => _confirmPlacement(placingSensor), + onCancel: () { + _cancelPlacement(); + context.go('/sensors'); + }, + ), + ), + ], + ], + ), ), - ), - if (mode == FloorPlanMode.edit && unplaced.isNotEmpty) - _UnplacedPanel(sensors: unplaced), - ], + if (placingSensor == null && + mode == FloorPlanMode.edit && + unplaced.isNotEmpty) + _UnplacedPanel(sensors: unplaced), + ], + ), ), ); } @@ -234,14 +303,13 @@ class _FloorPlanScreenState extends ConsumerState { ), ); - final name = nameCtrl.text.trim(); - final width = double.tryParse(widthCtrl.text) ?? 5.0; + final name = nameCtrl.text.trim(); + final width = double.tryParse(widthCtrl.text) ?? 5.0; final height = double.tryParse(heightCtrl.text) ?? 4.0; if (confirmed != true || !mounted) return; if (name.isEmpty) return; - // Create floor if none exists yet. var floor = ref.read(floorProvider).valueOrNull; if (floor == null) { floor = await ref @@ -262,6 +330,68 @@ class _FloorPlanScreenState extends ConsumerState { } } +class _PlacementDot extends StatelessWidget { + const _PlacementDot(); + + @override + Widget build(BuildContext context) { + return Container( + width: 16, + height: 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.primary, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [BoxShadow(blurRadius: 4, color: Colors.black26)], + ), + ); + } +} + +class _PlacementCard extends StatelessWidget { + const _PlacementCard({ + required this.sensor, + required this.onPlace, + required this.onCancel, + }); + + final Sensor sensor; + final VoidCallback onPlace; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + elevation: 4, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + const Icon(Icons.sensors), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(sensor.displayName, + style: theme.textTheme.labelLarge), + Text('Pan to position, then tap Place', + style: theme.textTheme.bodySmall), + ], + ), + ), + TextButton(onPressed: onCancel, child: const Text('Cancel')), + const SizedBox(width: 4), + FilledButton(onPressed: onPlace, child: const Text('Place')), + ], + ), + ), + ); + } +} + class _UnplacedPanel extends StatelessWidget { const _UnplacedPanel({required this.sensors}); @@ -291,7 +421,7 @@ class _UnplacedPanel extends StatelessWidget { size: 16), label: Text(s.displayName), onPressed: () => - context.push('/sensors/${s.id}'), + showSensorDetailSheet(context, s.id), ), ), ) diff --git a/lib/features/floorplan/widgets/floor_plan_editor.dart b/lib/features/floorplan/widgets/floor_plan_editor.dart index b61fac0..135aa4d 100644 --- a/lib/features/floorplan/widgets/floor_plan_editor.dart +++ b/lib/features/floorplan/widgets/floor_plan_editor.dart @@ -1,7 +1,9 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import 'package:vibration/vibration.dart'; import '../../../domain/models/floor.dart'; import '../../../domain/models/floor_plan_mode.dart'; @@ -43,6 +45,7 @@ class FloorPlanEditorState extends State { int? _selectedRoomId; String? _selectedSensorId; + Completer<({int? roomId, double? x, double? y})>? _placementCompleter; @override void initState() { @@ -82,6 +85,8 @@ class FloorPlanEditorState extends State { }); } else { widget.onSensorTapped(id); + Vibration.hasVibrator() + .then((t) { if(t) Vibration.vibrate(duration: 20); }); } case 'sensorMoved': widget.onSensorMoved( @@ -110,6 +115,12 @@ class FloorPlanEditorState extends State { _selectedRoomId = null; _selectedSensorId = null; }); + case 'positionAtCenter': + final roomId = (data['roomId'] as num?)?.toInt(); + final x = (data['x'] as num?)?.toDouble(); + final y = (data['y'] as num?)?.toDouble(); + _placementCompleter?.complete((roomId: roomId, x: x, y: y)); + _placementCompleter = null; } } @@ -208,6 +219,17 @@ class FloorPlanEditorState extends State { }); } + /// Asks JS for the room + room-relative coords at the canvas center. + /// Returns null roomId if no room is under the center dot. + Future<({int? roomId, double? x, double? y})> getPositionAtCenter() { + final c = Completer<({int? roomId, double? x, double? y})>(); + _placementCompleter = c; + _run(() async { + await _controller.runJavaScript('window.companion.getPositionAtCenter()'); + }); + return c.future; + } + @override Widget build(BuildContext context) { final inEditMode = widget.mode == FloorPlanMode.edit; diff --git a/lib/features/sensors/sensor_detail_sheet.dart b/lib/features/sensors/sensor_detail_sheet.dart new file mode 100644 index 0000000..4859631 --- /dev/null +++ b/lib/features/sensors/sensor_detail_sheet.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../domain/models/sensor.dart'; +import '../../providers.dart'; + +void showSensorDetailSheet(BuildContext context, int sensorId) { + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (_) => SensorDetailSheet(sensorId: sensorId), + ); +} + +class SensorDetailSheet extends ConsumerStatefulWidget { + const SensorDetailSheet({super.key, required this.sensorId}); + + final int sensorId; + + @override + ConsumerState createState() => _SensorDetailSheetState(); +} + +class _SensorDetailSheetState extends ConsumerState { + bool _editing = false; + final _nameCtrl = TextEditingController(); + + @override + void dispose() { + _nameCtrl.dispose(); + super.dispose(); + } + + Future _saveName(Sensor sensor) async { + final name = _nameCtrl.text.trim(); + setState(() => _editing = false); + final unchanged = name == (sensor.name ?? '') || + (name.isEmpty && sensor.name == null); + if (unchanged) return; + await ref + .read(sensorRepositoryProvider) + .updateSensor(sensor.id, name: name.isEmpty ? null : name); + ref.invalidate(sensorProvider(sensor.id)); + ref.invalidate(sensorsProvider); + } + + Future _delete(BuildContext context, Sensor sensor) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete sensor?'), + content: const Text('This will unenrol the sensor from the system.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + await ref.read(sensorRepositoryProvider).deleteSensor(sensor.id); + ref.invalidate(sensorsProvider); + if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); + } + } + + void _placeOnFloorPlan(BuildContext context, Sensor sensor) { + final router = GoRouter.of(context); + ref.read(sensorPlacementProvider.notifier).state = sensor; + Navigator.of(context, rootNavigator: true).pop(); + router.go('/floorplan'); + } + + @override + Widget build(BuildContext context) { + final sensorAsync = ref.watch(sensorProvider(widget.sensorId)); + + return sensorAsync.when( + loading: () => const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => SizedBox( + height: 200, + child: Center(child: Text(e.toString())), + ), + data: (sensor) { + // Keep controller in sync when not actively editing. + if (!_editing) _nameCtrl.text = sensor.name ?? ''; + + return _SheetBody( + sensor: sensor, + editing: _editing, + nameCtrl: _nameCtrl, + onEditToggle: () => setState(() { + _editing = true; + _nameCtrl.text = sensor.name ?? ''; + }), + onSaveName: () => _saveName(sensor), + onPlace: () => _placeOnFloorPlan(context, sensor), + onDelete: () => _delete(context, sensor), + ); + }, + ); + } +} + +class _SheetBody extends StatelessWidget { + const _SheetBody({ + required this.sensor, + required this.editing, + required this.nameCtrl, + required this.onEditToggle, + required this.onSaveName, + required this.onPlace, + required this.onDelete, + }); + + final Sensor sensor; + final bool editing; + final TextEditingController nameCtrl; + final VoidCallback onEditToggle; + final VoidCallback onSaveName; + final VoidCallback onPlace; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + 12, + 24, + MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 32, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: editing + ? TextField( + controller: nameCtrl, + autofocus: true, + style: theme.textTheme.titleLarge, + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + ), + onSubmitted: (_) => onSaveName(), + ) + : Text( + sensor.displayName, + style: theme.textTheme.titleLarge, + ), + ), + IconButton( + icon: Icon(editing ? Icons.check : Icons.edit_outlined), + tooltip: editing ? 'Save name' : 'Rename', + onPressed: editing ? onSaveName : onEditToggle, + ), + ], + ), + const SizedBox(height: 16), + _InfoRow(label: 'Device ID', value: sensor.sensorId), + _InfoRow( + label: 'Position', + value: sensor.isPlaced + ? '(${sensor.x!.toStringAsFixed(2)}, ${sensor.y!.toStringAsFixed(2)})' + : 'Not placed', + ), + const SizedBox(height: 24), + FilledButton.icon( + icon: const Icon(Icons.map_outlined), + label: Text(sensor.isPlaced + ? 'Reposition on floor plan' + : 'Place on floor plan'), + onPressed: onPlace, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + icon: const Icon(Icons.bluetooth), + label: const Text('Reprovision'), + onPressed: () {}, + ), + const SizedBox(height: 24), + TextButton( + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + onPressed: onDelete, + child: const Text('Delete sensor'), + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({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: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } +} diff --git a/lib/features/sensors/sensor_list_screen.dart b/lib/features/sensors/sensor_list_screen.dart index 0b85929..5e373af 100644 --- a/lib/features/sensors/sensor_list_screen.dart +++ b/lib/features/sensors/sensor_list_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../domain/models/sensor.dart'; import '../../providers.dart'; import '../ble_provision/ble_provision_sheet.dart'; +import 'sensor_detail_sheet.dart'; class SensorListScreen extends ConsumerWidget { const SensorListScreen({super.key}); @@ -26,7 +28,8 @@ class SensorListScreen extends ConsumerWidget { child: const Text('Dismiss'), ), TextButton( - onPressed: () => context.push('/sensors/$selectedId'), + onPressed: () => + showSensorDetailSheet(context, int.parse(selectedId)), child: const Text('Open'), ), ], @@ -36,29 +39,37 @@ class SensorListScreen extends ConsumerWidget { loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text(e.toString())), - data: (list) => list.isEmpty - ? const Center(child: Text('No sensors enrolled yet')) - : ListView.builder( - itemCount: list.length, - itemBuilder: (context, i) { - final sensor = list[i]; - final isSelected = - selectedId == sensor.id.toString(); - 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), - onTap: () => - context.push('/sensors/${sensor.id}'), - ); - }, - ), + data: (list) { + if (list.isEmpty) { + return const Center(child: Text('No sensors enrolled yet')); + } + final unplaced = + list.where((s) => !s.isPlaced).toList(); + final placed = + list.where((s) => s.isPlaced).toList(); + return ListView( + children: [ + if (unplaced.isNotEmpty) ...[ + _SectionHeader( + label: 'Unplaced', count: unplaced.length), + ...unplaced.map((s) => _SensorTile( + sensor: s, + isSelected: + selectedId == s.id.toString(), + )), + ], + if (placed.isNotEmpty) ...[ + _SectionHeader( + label: 'Placed', count: placed.length), + ...placed.map((s) => _SensorTile( + sensor: s, + isSelected: + selectedId == s.id.toString(), + )), + ], + ], + ); + }, ), ), ], @@ -75,3 +86,45 @@ class SensorListScreen extends ConsumerWidget { ); } } + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label, required this.count}); + + final String label; + final int count; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + '$label ($count)', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _SensorTile extends StatelessWidget { + const _SensorTile({required this.sensor, required this.isSelected}); + + 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), + onTap: () => showSensorDetailSheet(context, sensor.id), + ); + } +} diff --git a/lib/providers.dart b/lib/providers.dart index 512aa4b..82c9bd6 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -148,6 +148,10 @@ final sensorProvider = final selectedSensorIdProvider = StateProvider((ref) => null); +/// Non-null while the user is placing a sensor on the floor plan via the +/// center-dot placement flow. Cleared on Place, Cancel, or back navigation. +final sensorPlacementProvider = StateProvider((ref) => null); + final floorPlanModeProvider = StateProvider((ref) => FloorPlanMode.view); diff --git a/lib/router.dart b/lib/router.dart index ebc5055..86a49b5 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -57,28 +57,12 @@ final routerProvider = Provider((ref) { GoRoute( path: '/floorplan', builder: (context, state) => const FloorPlanScreen(), - routes: [ - GoRoute( - path: 'sensors/:id', - builder: (context, state) => SensorDetailScreen( - sensorId: state.pathParameters['id']!, - ), - ), - ], ), ]), StatefulShellBranch(routes: [ GoRoute( path: '/sensors', builder: (context, state) => const SensorListScreen(), - routes: [ - GoRoute( - path: ':id', - builder: (context, state) => SensorDetailScreen( - sensorId: state.pathParameters['id']!, - ), - ), - ], ), ]), StatefulShellBranch(routes: [ diff --git a/pubspec.lock b/pubspec.lock index 4f67f41..0aeb714 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" equatable: dependency: transitive description: @@ -701,6 +717,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: "9bb06614c69260f8bd11c80fe01ed7988905cf00e3417d656c2647e41f261d87" + url: "https://pub.dev" + source: hosted + version: "3.1.8" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "258c273268f8aa40c88d29741137c536874a738779b92ddb8aa32ed093721ec5" + url: "https://pub.dev" + source: hosted + version: "0.1.2" vm_service: dependency: transitive description: @@ -773,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 53b8a4a..669d87e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: flutter_secure_storage: ^9.2.2 protobuf: ^6.0.0 permission_handler: ^11.0.0 + vibration: ^3.1.3 dev_dependencies: flutter_test: