From eee10a8121e59d40f5f4e3d049d7f741fa914cec Mon Sep 17 00:00:00 2001 From: dvdrw Date: Tue, 12 May 2026 16:44:10 +0200 Subject: [PATCH] feat: implement Flutter side of floor plan screen and widget --- lib/features/floorplan/floor_plan_screen.dart | 288 ++++++++++++++++-- .../floorplan/widgets/konva_web_view.dart | 132 ++++++-- 2 files changed, 366 insertions(+), 54 deletions(-) diff --git a/lib/features/floorplan/floor_plan_screen.dart b/lib/features/floorplan/floor_plan_screen.dart index c314021..eb81720 100644 --- a/lib/features/floorplan/floor_plan_screen.dart +++ b/lib/features/floorplan/floor_plan_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../domain/models/floor_plan_mode.dart'; +import '../../domain/models/sensor.dart'; import '../../providers.dart'; import '../ble_provision/ble_provision_sheet.dart'; import 'widgets/konva_web_view.dart'; @@ -17,24 +18,49 @@ class FloorPlanScreen extends ConsumerStatefulWidget { class _FloorPlanScreenState extends ConsumerState { final _konvaKey = GlobalKey(); + @override + void initState() { + super.initState(); + // Push any already-loaded data once the WebView is ready. + WidgetsBinding.instance.addPostFrameCallback((_) => _syncAll()); + } + + void _syncAll() { + ref + .read(roomsProvider.future) + .then((rooms) => _konvaKey.currentState?.loadFloorPlan(rooms)); + ref + .read(sensorsProvider.future) + .then((sensors) => _konvaKey.currentState?.loadSensors(sensors)); + } + @override Widget build(BuildContext context) { final mode = ref.watch(floorPlanModeProvider); + final roomsAsync = ref.watch(roomsProvider); + final sensorsAsync = ref.watch(sensorsProvider); - // TODO: forward live tag positions into the WebView. - // ref.listen(tagPositionsProvider, (_, next) { - // next.whenData((positions) => _konvaKey.currentState?.updateTags(positions)); - // }); + // Push data updates to the WebView whenever providers refresh. + ref.listen(roomsProvider, (_, next) { + next.whenData((r) => _konvaKey.currentState?.loadFloorPlan(r)); + }); + ref.listen(sensorsProvider, (_, next) { + next.whenData((s) => _konvaKey.currentState?.loadSensors(s)); + }); + ref.listen(tagPositionsProvider, (_, next) { + next.whenData((t) => _konvaKey.currentState?.updateTags(t)); + }); + ref.listen(particleCloudProvider, (_, next) { + next.whenData((p) => _konvaKey.currentState?.updateParticleCloud(p)); + }); + ref.listen(selectedSensorIdProvider, (_, id) { + _konvaKey.currentState?.highlightSensor(id); + }); - // TODO: forward particle cloud updates into the WebView. - // ref.listen(particleCloudProvider, (_, next) { - // next.whenData((particles) => _konvaKey.currentState?.updateParticleCloud(particles)); - // }); - - // TODO: react to selectedSensorIdProvider and highlight sensor in WebView. - // ref.listen(selectedSensorIdProvider, (_, id) { - // _konvaKey.currentState?.highlightSensor(id); - // }); + final unplaced = sensorsAsync.valueOrNull + ?.where((s) => !s.isPlaced) + .toList() ?? + []; return Scaffold( appBar: AppBar( @@ -55,28 +81,228 @@ class _FloorPlanScreenState extends ConsumerState { ), ], ), - body: KonvaWebView( - key: _konvaKey, - mode: mode, - onSensorTapped: (id) { - ref.read(selectedSensorIdProvider.notifier).state = id; - // TODO: optionally navigate to sensor detail or show tooltip. - }, - onSensorMoved: (id, position) { - // TODO: persist new position via sensorRepositoryProvider. - }, + body: Column( + children: [ + Expanded( + child: Stack( + children: [ + KonvaWebView( + key: _konvaKey, + 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), + ), + 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, + ), + ], + ), + ), + ], + ), + ), + if (mode == FloorPlanMode.edit && unplaced.isNotEmpty) + _UnplacedPanel(sensors: unplaced), + ], ), floatingActionButton: mode == FloorPlanMode.edit - ? FloatingActionButton.extended( - icon: const Icon(Icons.add), - label: const Text('Add sensor'), - onPressed: () => showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => const BleProvisionSheet(), - ), + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + FloatingActionButton.small( + heroTag: 'add-room', + tooltip: 'Add room', + onPressed: () => _konvaKey.currentState?.addRoom(), + child: const Icon(Icons.add_home_outlined), + ), + const SizedBox(height: 8), + FloatingActionButton.extended( + heroTag: 'add-sensor', + icon: const Icon(Icons.bluetooth), + label: const Text('Add sensor'), + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const BleProvisionSheet(), + ).then((_) { + ref.invalidate(sensorsProvider); + }), + ), + ], ) : null, ); } + + Future _showAddRoomDialog(double x, double y) async { + final nameCtrl = TextEditingController(); + final widthCtrl = TextEditingController(text: '5.0'); + final heightCtrl = TextEditingController(text: '4.0'); + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Add room'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: 'Name'), + autofocus: true, + onSubmitted: (_) => Navigator.of(ctx).pop(true), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: widthCtrl, + decoration: const InputDecoration( + labelText: 'Width', suffixText: 'm'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: heightCtrl, + decoration: const InputDecoration( + labelText: 'Height', suffixText: 'm'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Add'), + ), + ], + ), + ); + + 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 + .read(floorRepositoryProvider) + .createFloor(name: 'Ground Floor'); + ref.invalidate(floorProvider); + } + + await ref.read(floorRepositoryProvider).createRoom( + floor.id, + name: name, + width: width, + height: height, + x: x, + y: y, + ); + ref.invalidate(roomsProvider); + } +} + +class _UnplacedPanel extends StatelessWidget { + const _UnplacedPanel({required this.sensors}); + + final List sensors; + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.surfaceContainerLow, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Unplaced sensors', + style: Theme.of(context).textTheme.labelSmall), + const SizedBox(height: 4), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: sensors + .map( + (s) => Padding( + padding: const EdgeInsets.only(right: 8), + child: ActionChip( + avatar: const Icon(Icons.sensors_off_outlined, + size: 16), + label: Text(s.displayName), + onPressed: () => + context.push('/sensors/${s.id}'), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ); + } } diff --git a/lib/features/floorplan/widgets/konva_web_view.dart b/lib/features/floorplan/widgets/konva_web_view.dart index 25fe846..8e60f39 100644 --- a/lib/features/floorplan/widgets/konva_web_view.dart +++ b/lib/features/floorplan/widgets/konva_web_view.dart @@ -3,10 +3,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import '../../../domain/models/floor.dart'; import '../../../domain/models/floor_plan_mode.dart'; import '../../../domain/models/tag.dart'; import '../../../domain/models/particle.dart'; -import '../../../domain/models/position.dart'; +import '../../../domain/models/sensor.dart'; class KonvaWebView extends StatefulWidget { const KonvaWebView({ @@ -14,11 +15,15 @@ class KonvaWebView extends StatefulWidget { required this.mode, required this.onSensorTapped, required this.onSensorMoved, + this.onRoomsUpdated, + this.onRoomAdded, }); final FloorPlanMode mode; final void Function(String sensorId) onSensorTapped; - final void Function(String sensorId, Position newPosition) onSensorMoved; + final void Function(String sensorId, int roomId, double x, double y) onSensorMoved; + final void Function(List rooms)? onRoomsUpdated; + final void Function(double x, double y)? onRoomAdded; @override State createState() => KonvaWebViewState(); @@ -26,6 +31,8 @@ class KonvaWebView extends StatefulWidget { class KonvaWebViewState extends State { late final WebViewController _controller; + bool _ready = false; + final _pending = Function()>[]; @override void initState() { @@ -33,9 +40,26 @@ class KonvaWebViewState extends State { _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel('FlutterBridge', onMessageReceived: _onMessage) + ..setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => _onPageReady()), + ) ..loadFlutterAsset('assets/konva/index.html'); } + void _onPageReady() { + _ready = true; + for (final call in _pending) { + call(); + } + _pending.clear(); + } + + Future _run(Future Function() call) { + if (_ready) return call(); + _pending.add(call); + return Future.value(); + } + void _onMessage(JavaScriptMessage message) { final data = jsonDecode(message.message) as Map; switch (data['type'] as String?) { @@ -44,37 +68,99 @@ class KonvaWebViewState extends State { case 'sensorMoved': widget.onSensorMoved( data['id'] as String, - Position( - x: (data['x'] as num).toDouble(), - y: (data['y'] as num).toDouble(), - ), + (data['roomId'] as num).toInt(), + (data['x'] as num).toDouble(), + (data['y'] as num).toDouble(), + ); + case 'roomsUpdated': + final rooms = (data['rooms'] as List) + .map((r) => Room.fromJson(r as Map)) + .toList(); + widget.onRoomsUpdated?.call(rooms); + case 'roomAdded': + widget.onRoomAdded?.call( + (data['x'] as num).toDouble(), + (data['y'] as num).toDouble(), ); } } - Future updateTags(List positions) async { - final payload = jsonEncode(positions - .map((p) => {'tagId': p.tagId, 'x': p.position.x, 'y': p.position.y}) - .toList()); - await _controller.runJavaScript('window.companion.updateTags($payload)'); + Future loadFloorPlan(List rooms) { + return _run(() async { + final payload = jsonEncode(rooms + .map((r) => { + 'id': r.id, + 'name': r.name, + 'x': r.x, + 'y': r.y, + 'width': r.width, + 'height': r.height, + }) + .toList()); + await _controller + .runJavaScript('window.companion.loadFloorPlan($payload)'); + }); } - Future updateParticleCloud(List particles) async { - final payload = jsonEncode(particles - .map((p) => {'x': p.x, 'y': p.y, 'weight': p.weight}) - .toList()); - await _controller.runJavaScript('window.companion.updateCloud($payload)'); + Future loadSensors(List sensors) { + return _run(() async { + final payload = jsonEncode(sensors + .map((s) => { + 'id': s.id, + 'sensor_id': s.sensorId, + 'name': s.name, + 'floor_x': s.x, + 'floor_y': s.y, + 'room_id': s.roomId, + }) + .toList()); + await _controller + .runJavaScript('window.companion.loadSensors($payload)'); + }); } - Future highlightSensor(String? sensorId) async { - final id = sensorId == null ? 'null' : '"$sensorId"'; - await _controller.runJavaScript('window.companion.highlightSensor($id)'); + Future updateTags(List positions) { + return _run(() async { + final payload = jsonEncode(positions + .map((p) => { + 'tagId': p.tagId, + 'roomId': p.roomId, + 'name': p.name, + 'color': p.color, + }) + .toList()); + await _controller.runJavaScript('window.companion.updateTags($payload)'); + }); } - Future setMode(FloorPlanMode mode) async { - await _controller.runJavaScript( - 'window.companion.setMode("${mode.name}")', - ); + Future updateParticleCloud(List particles) { + return _run(() async { + final payload = jsonEncode(particles + .map((p) => {'x': p.x, 'y': p.y, 'weight': p.weight}) + .toList()); + await _controller.runJavaScript('window.companion.updateCloud($payload)'); + }); + } + + Future highlightSensor(String? sensorId) { + return _run(() async { + final id = sensorId == null ? 'null' : jsonEncode(sensorId); + await _controller + .runJavaScript('window.companion.highlightSensor($id)'); + }); + } + + Future setMode(FloorPlanMode mode) { + return _run(() async { + await _controller + .runJavaScript('window.companion.setMode("${mode.name}")'); + }); + } + + Future addRoom() { + return _run(() async { + await _controller.runJavaScript('window.companion.addRoom()'); + }); } @override