import 'package:flutter/material.dart'; 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'; class FloorPlanScreen extends ConsumerStatefulWidget { const FloorPlanScreen({super.key}); @override ConsumerState createState() => _FloorPlanScreenState(); } 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); // 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); }); final unplaced = sensorsAsync.valueOrNull ?.where((s) => !s.isPlaced) .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; _konvaKey.currentState?.setMode(next); }, ), ], ), 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 ? 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(), ), ), ], ), ); } }