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 '../sensors/sensor_detail_sheet.dart'; import 'widgets/floor_plan_editor.dart'; import 'widgets/room_edit_sheet.dart'; class FloorPlanScreen extends ConsumerStatefulWidget { const FloorPlanScreen({super.key}); @override ConsumerState createState() => _FloorPlanScreenState(); } class _FloorPlanScreenState extends ConsumerState { final _editorKey = GlobalKey(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _syncAll()); } void _syncAll() { ref .read(roomsProvider.future) .then((rooms) => _editorKey.currentState?.loadFloorPlan(rooms)); ref .read(sensorsProvider.future) .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 _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)); }); 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)); }); ref.listen(particleCloudProvider, (_, next) { next.whenData((p) => _editorKey.currentState?.updateParticleCloud(p)); }); ref.listen(sensorPlacementProvider, (prev, next) => _applyPlacementState()); final unplaced = sensorsAsync.valueOrNull ?.where((s) => !s.isPlaced) .toList() ?? []; 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) ...[ if (mode == FloorPlanMode.view) ...[ IconButton( tooltip: 'Edit mode', icon: const Icon(Icons.edit), onPressed: () { ref.read(floorPlanModeProvider.notifier).state = FloorPlanMode.edit; _editorKey.currentState?.setMode(FloorPlanMode.edit); }, ), 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( children: [ Expanded( child: Stack( children: [ FloorPlanEditor( key: _editorKey, mode: mode, onSensorTapped: (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, 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 (placingSensor == null && mode == FloorPlanMode.edit && unplaced.isNotEmpty) _UnplacedPanel(sensors: unplaced), ], ), ), ); } void _showRoomEditSheet(int roomId) { final rooms = ref.read(roomsProvider).valueOrNull ?? []; final room = rooms.where((r) => r.id == roomId).firstOrNull; if (room == null) return; final floor = ref.read(floorProvider).valueOrNull; if (floor == null) return; showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, builder: (_) => RoomEditSheet(room: room, floorId: floor.id), ).then((_) => ref.invalidate(roomsProvider)); } 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; 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 _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}); 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: () => showSensorDetailSheet(context, s.id), ), ), ) .toList(), ), ), ], ), ); } }