feat: add sensor move mode to floor plan

This commit is contained in:
2026-05-15 15:46:13 +02:00
parent 2c3e60d2b1
commit 077585bd73
8 changed files with 99 additions and 94 deletions
+19 -6
View File
@@ -364,7 +364,7 @@ function renderSensors() {
const group = new Konva.Group({ const group = new Konva.Group({
id: `sensor-${sensor.id}`, id: `sensor-${sensor.id}`,
x: absX, y: absY, x: absX, y: absY,
draggable: mode === 'edit', draggable: mode === 'sensorMove',
}); });
group.add(new Konva.Circle({ group.add(new Konva.Circle({
@@ -395,8 +395,15 @@ function renderSensors() {
listening: false, listening: false,
})); }));
group.on('click tap', () => {
if (mode === 'view') {
notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) });
}
});
let pressTimer = null; let pressTimer = null;
group.on('mousedown touchstart', () => { group.on('mousedown touchstart', () => {
if (mode === 'view') return;
pressTimer = setTimeout(() => { pressTimer = setTimeout(() => {
notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) }); notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) });
pressTimer = null; pressTimer = null;
@@ -544,15 +551,21 @@ window.companion = {
setMode(newMode) { setMode(newMode) {
mode = newMode; mode = newMode;
const edit = newMode === 'edit'; roomsLayer.find('Group').forEach(g => g.draggable(newMode === 'edit'));
roomsLayer.find('Group').forEach(g => g.draggable(edit)); sensorsLayer.find('Group').forEach(g => g.draggable(newMode === 'sensorMove'));
sensorsLayer.find('Group').forEach(g => g.draggable(edit)); tagsLayer.visible(newMode === 'view');
tagsLayer.visible(!edit); particlesLayer.visible(newMode === 'view');
particlesLayer.visible(!edit);
_clearDimensions(); _clearDimensions();
stage.batchDraw(); stage.batchDraw();
}, },
setRepositioningSensor(id) {
sensorsLayer.find('Group').forEach(g => {
g.draggable(mode === 'sensorMove' && (id == null || g.id() === `sensor-${id}`));
});
stage.batchDraw();
},
highlightSensor(id) { highlightSensor(id) {
sensorsLayer.find('.highlight-ring').forEach(r => r.visible(false)); sensorsLayer.find('.highlight-ring').forEach(r => r.visible(false));
if (id) { if (id) {
+1 -1
View File
@@ -1 +1 @@
enum FloorPlanMode { view, edit } enum FloorPlanMode { view, edit, sensorMove }
+48 -21
View File
@@ -32,7 +32,26 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
.then((rooms) => _editorKey.currentState?.loadFloorPlan(rooms)); .then((rooms) => _editorKey.currentState?.loadFloorPlan(rooms));
ref ref
.read(sensorsProvider.future) .read(sensorsProvider.future)
.then((sensors) => _editorKey.currentState?.loadSensors(sensors)); .then((sensors) {
_editorKey.currentState?.loadSensors(sensors);
_applyPlacementState();
});
}
void _applyPlacementState() {
final placing = ref.read(sensorPlacementProvider);
if (placing == null) {
_editorKey.currentState?.highlightSensor(null);
return;
}
final currentMode = ref.read(floorPlanModeProvider);
if (currentMode != FloorPlanMode.view) {
ref.read(floorPlanModeProvider.notifier).state = FloorPlanMode.view;
_editorKey.currentState?.setMode(FloorPlanMode.view);
}
if (placing.isPlaced) {
_editorKey.currentState?.highlightSensor(placing.id.toString());
}
} }
Future<void> _confirmPlacement(Sensor sensor) async { Future<void> _confirmPlacement(Sensor sensor) async {
@@ -81,9 +100,7 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
ref.listen(particleCloudProvider, (_, next) { ref.listen(particleCloudProvider, (_, next) {
next.whenData((p) => _editorKey.currentState?.updateParticleCloud(p)); next.whenData((p) => _editorKey.currentState?.updateParticleCloud(p));
}); });
ref.listen(selectedSensorIdProvider, (_, id) { ref.listen(sensorPlacementProvider, (prev, next) => _applyPlacementState());
_editorKey.currentState?.highlightSensor(id);
});
final unplaced = sensorsAsync.valueOrNull final unplaced = sensorsAsync.valueOrNull
?.where((s) => !s.isPlaced) ?.where((s) => !s.isPlaced)
@@ -103,23 +120,37 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Floor Plan'), title: const Text('Floor Plan'),
actions: [ actions: [
if (placingSensor == null) if (placingSensor == null) ...[
if (mode == FloorPlanMode.view) ...[
IconButton( IconButton(
tooltip: tooltip: 'Edit mode',
mode == FloorPlanMode.edit ? 'View mode' : 'Edit mode', icon: const Icon(Icons.edit),
icon: Icon(
mode == FloorPlanMode.edit
? Icons.visibility
: Icons.edit,
),
onPressed: () { onPressed: () {
final next = mode == FloorPlanMode.edit ref.read(floorPlanModeProvider.notifier).state =
? FloorPlanMode.view FloorPlanMode.edit;
: FloorPlanMode.edit; _editorKey.currentState?.setMode(FloorPlanMode.edit);
ref.read(floorPlanModeProvider.notifier).state = next;
_editorKey.currentState?.setMode(next);
}, },
), ),
IconButton(
tooltip: 'Sensor move mode',
icon: const Icon(Icons.sensors),
onPressed: () {
ref.read(floorPlanModeProvider.notifier).state =
FloorPlanMode.sensorMove;
_editorKey.currentState?.setMode(FloorPlanMode.sensorMove);
},
),
] else
IconButton(
tooltip: 'Done',
icon: const Icon(Icons.check),
onPressed: () {
ref.read(floorPlanModeProvider.notifier).state =
FloorPlanMode.view;
_editorKey.currentState?.setMode(FloorPlanMode.view);
},
),
],
], ],
), ),
body: Column( body: Column(
@@ -131,8 +162,6 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
key: _editorKey, key: _editorKey,
mode: mode, mode: mode,
onSensorTapped: (id) { onSensorTapped: (id) {
ref.read(selectedSensorIdProvider.notifier).state =
id;
showSensorDetailSheet(context, int.parse(id)); showSensorDetailSheet(context, int.parse(id));
}, },
onSensorMoved: (sensorId, roomId, x, y) async { onSensorMoved: (sensorId, roomId, x, y) async {
@@ -163,8 +192,6 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
}, },
onRoomAdded: (x, y) => _showAddRoomDialog(x, y), onRoomAdded: (x, y) => _showAddRoomDialog(x, y),
onEditRoomTapped: _showRoomEditSheet, onEditRoomTapped: _showRoomEditSheet,
onEditSensorTapped: (id) =>
showSensorDetailSheet(context, int.parse(id)),
onAddSensorTapped: () => showModalBottomSheet<void>( onAddSensorTapped: () => showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -20,7 +20,6 @@ class FloorPlanEditor extends StatefulWidget {
this.onRoomsUpdated, this.onRoomsUpdated,
this.onRoomAdded, this.onRoomAdded,
this.onEditRoomTapped, this.onEditRoomTapped,
this.onEditSensorTapped,
this.onAddSensorTapped, this.onAddSensorTapped,
}); });
@@ -31,7 +30,6 @@ class FloorPlanEditor extends StatefulWidget {
final void Function(List<Room> rooms)? onRoomsUpdated; final void Function(List<Room> rooms)? onRoomsUpdated;
final void Function(double x, double y)? onRoomAdded; final void Function(double x, double y)? onRoomAdded;
final void Function(int roomId)? onEditRoomTapped; final void Function(int roomId)? onEditRoomTapped;
final void Function(String sensorId)? onEditSensorTapped;
final void Function()? onAddSensorTapped; final void Function()? onAddSensorTapped;
@override @override
@@ -44,7 +42,7 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
final _pending = <Future<void> Function()>[]; final _pending = <Future<void> Function()>[];
int? _selectedRoomId; int? _selectedRoomId;
String? _selectedSensorId; String? _repositioningSensorId;
Completer<({int? roomId, double? x, double? y})>? _placementCompleter; Completer<({int? roomId, double? x, double? y})>? _placementCompleter;
@override @override
@@ -78,19 +76,17 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
switch (data['type'] as String?) { switch (data['type'] as String?) {
case 'sensorTapped': case 'sensorTapped':
final id = data['id'] as String; final id = data['id'] as String;
if (widget.mode == FloorPlanMode.edit) {
setState(() {
_selectedSensorId = id;
_selectedRoomId = null;
});
} else {
widget.onSensorTapped(id); widget.onSensorTapped(id);
Vibration.hasVibrator() Vibration.hasVibrator()
.then((t) { if(t) Vibration.vibrate(duration: 20); }); .then((t) { if (t) Vibration.vibrate(duration: 20); });
}
case 'sensorMoved': case 'sensorMoved':
final id = data['id'] as String;
if (_repositioningSensorId == id) {
setState(() => _repositioningSensorId = null);
setRepositioningSensor(null);
}
widget.onSensorMoved( widget.onSensorMoved(
data['id'] as String, id,
(data['roomId'] as num).toInt(), (data['roomId'] as num).toInt(),
(data['x'] as num).toDouble(), (data['x'] as num).toDouble(),
(data['y'] as num).toDouble(), (data['y'] as num).toDouble(),
@@ -106,15 +102,9 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
(data['y'] as num).toDouble(), (data['y'] as num).toDouble(),
); );
case 'roomTapped': case 'roomTapped':
setState(() { setState(() => _selectedRoomId = (data['id'] as num).toInt());
_selectedRoomId = (data['id'] as num).toInt();
_selectedSensorId = null;
});
case 'selectionCleared': case 'selectionCleared':
setState(() { setState(() => _selectedRoomId = null);
_selectedRoomId = null;
_selectedSensorId = null;
});
case 'positionAtCenter': case 'positionAtCenter':
final roomId = (data['roomId'] as num?)?.toInt(); final roomId = (data['roomId'] as num?)?.toInt();
final x = (data['x'] as num?)?.toDouble(); final x = (data['x'] as num?)?.toDouble();
@@ -204,7 +194,7 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
Future<void> setMode(FloorPlanMode mode) { Future<void> setMode(FloorPlanMode mode) {
setState(() { setState(() {
_selectedRoomId = null; _selectedRoomId = null;
_selectedSensorId = null; _repositioningSensorId = null;
}); });
return _run(() async { return _run(() async {
await _controller.runJavaScript( await _controller.runJavaScript(
@@ -213,6 +203,16 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
}); });
} }
Future<void> setRepositioningSensor(String? id) {
setState(() => _repositioningSensorId = id);
return _run(() async {
final jsId = id == null ? 'null' : jsonEncode(id);
await _controller.runJavaScript(
'window.companion.setRepositioningSensor($jsId)',
);
});
}
Future<void> addRoom() { Future<void> addRoom() {
return _run(() async { return _run(() async {
await _controller.runJavaScript('window.companion.addRoom()'); await _controller.runJavaScript('window.companion.addRoom()');
@@ -245,16 +245,6 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (inEditMode) ...[ if (inEditMode) ...[
if (_selectedSensorId != null) ...[
FloatingActionButton.small(
heroTag: 'editor-info-sensor',
tooltip: 'Sensor details',
onPressed: () =>
widget.onEditSensorTapped?.call(_selectedSensorId!),
child: const Icon(Icons.info_outline),
),
const SizedBox(height: 8),
],
if (_selectedRoomId != null) ...[ if (_selectedRoomId != null) ...[
FloatingActionButton.small( FloatingActionButton.small(
heroTag: 'editor-edit-room', heroTag: 'editor-edit-room',
@@ -59,10 +59,7 @@ class _Body extends ConsumerWidget {
OutlinedButton.icon( OutlinedButton.icon(
icon: const Icon(Icons.map_outlined), icon: const Icon(Icons.map_outlined),
label: const Text('Locate on floor plan'), label: const Text('Locate on floor plan'),
onPressed: () { onPressed: () => context.go('/floorplan'),
ref.read(selectedSensorIdProvider.notifier).state = sensorId;
context.go('/floorplan');
},
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton.icon( OutlinedButton.icon(
@@ -20,7 +20,8 @@ class SensorDetailSheet extends ConsumerStatefulWidget {
final int sensorId; final int sensorId;
@override @override
ConsumerState<SensorDetailSheet> createState() => _SensorDetailSheetState(); ConsumerState<SensorDetailSheet> createState() =>
_SensorDetailSheetState();
} }
class _SensorDetailSheetState extends ConsumerState<SensorDetailSheet> { class _SensorDetailSheetState extends ConsumerState<SensorDetailSheet> {
@@ -196,7 +197,7 @@ class _SheetBody extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.map_outlined), icon: Icon(Icons.my_location),
label: Text(sensor.isPlaced label: Text(sensor.isPlaced
? 'Reposition on floor plan' ? 'Reposition on floor plan'
: 'Place on floor plan'), : 'Place on floor plan'),
+1 -22
View File
@@ -12,28 +12,11 @@ class SensorListScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final sensors = ref.watch(sensorsProvider); final sensors = ref.watch(sensorsProvider);
final selectedId = ref.watch(selectedSensorIdProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Sensors')), appBar: AppBar(title: const Text('Sensors')),
body: Column( body: Column(
children: [ children: [
if (selectedId != null)
MaterialBanner(
content: Text('Sensor $selectedId selected on floor plan'),
actions: [
TextButton(
onPressed: () =>
ref.read(selectedSensorIdProvider.notifier).state = null,
child: const Text('Dismiss'),
),
TextButton(
onPressed: () =>
showSensorDetailSheet(context, int.parse(selectedId)),
child: const Text('Open'),
),
],
),
Expanded( Expanded(
child: sensors.when( child: sensors.when(
loading: () => loading: () =>
@@ -54,8 +37,6 @@ class SensorListScreen extends ConsumerWidget {
label: 'Unplaced', count: unplaced.length), label: 'Unplaced', count: unplaced.length),
...unplaced.map((s) => _SensorTile( ...unplaced.map((s) => _SensorTile(
sensor: s, sensor: s,
isSelected:
selectedId == s.id.toString(),
)), )),
], ],
if (placed.isNotEmpty) ...[ if (placed.isNotEmpty) ...[
@@ -63,8 +44,6 @@ class SensorListScreen extends ConsumerWidget {
label: 'Placed', count: placed.length), label: 'Placed', count: placed.length),
...placed.map((s) => _SensorTile( ...placed.map((s) => _SensorTile(
sensor: s, sensor: s,
isSelected:
selectedId == s.id.toString(),
)), )),
], ],
], ],
@@ -108,7 +87,7 @@ class _SectionHeader extends StatelessWidget {
} }
class _SensorTile extends StatelessWidget { class _SensorTile extends StatelessWidget {
const _SensorTile({required this.sensor, required this.isSelected}); const _SensorTile({required this.sensor, this.isSelected = false});
final Sensor sensor; final Sensor sensor;
final bool isSelected; final bool isSelected;
-2
View File
@@ -146,8 +146,6 @@ final sensorProvider =
// Cross-tab UI state // Cross-tab UI state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
final selectedSensorIdProvider = StateProvider<String?>((ref) => null);
/// Non-null while the user is placing a sensor on the floor plan via the /// 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. /// center-dot placement flow. Cleared on Place, Cancel, or back navigation.
final sensorPlacementProvider = StateProvider<Sensor?>((ref) => null); final sensorPlacementProvider = StateProvider<Sensor?>((ref) => null);