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/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)); } @override Widget build(BuildContext context) { final mode = ref.watch(floorPlanModeProvider); final roomsAsync = ref.watch(roomsProvider); final sensorsAsync = ref.watch(sensorsProvider); ref.listen(roomsProvider, (_, next) { next.whenData((r) => _editorKey.currentState?.loadFloorPlan(r)); }); ref.listen(sensorsProvider, (_, next) { next.whenData((s) => _editorKey.currentState?.loadSensors(s)); }); ref.listen(tagPositionsProvider, (_, next) { next.whenData((t) => _editorKey.currentState?.updateTags(t)); }); ref.listen(particleCloudProvider, (_, next) { next.whenData((p) => _editorKey.currentState?.updateParticleCloud(p)); }); ref.listen(selectedSensorIdProvider, (_, id) { _editorKey.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; _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); }), ), 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), ], ), ); } 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; // 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(), ), ), ], ), ); } }