import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../domain/models/floor.dart'; import '../../domain/models/floor_plan_mode.dart'; import '../../domain/models/sensor.dart'; import '../../domain/models/tag.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(); List? _editModeOriginalRooms; List? _pendingRoomUpdates; @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; ref.read(sensorPlacementOriginSensorsProvider.notifier).state = false; } @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); final trackedTag = ref.watch(trackedTagProvider); 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)); }); if (trackedTag != null) { ref.listen(particleSnapshotProvider(trackedTag.tagId), (_, next) { next.whenData( (snap) => _editorKey.currentState?.updateParticleCloud(snap.particles), ); }); } ref.listen(trackedTagProvider, (prev, next) { if (next == null) _editorKey.currentState?.updateParticleCloud([]); }); 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) { final fromSensors = ref.read(sensorPlacementOriginSensorsProvider); _cancelPlacement(); if (fromSensors) 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: () { _editModeOriginalRooms = ref .read(roomsProvider) .valueOrNull ?.toList(); _pendingRoomUpdates = null; 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 ...[ if (mode == FloorPlanMode.edit) ...[ IconButton( tooltip: 'Discard changes', icon: const Icon(Icons.close), onPressed: _discardRoomEdits, ), IconButton( tooltip: 'Save changes', icon: const Icon(Icons.check), onPressed: _saveRoomEdits, ), ] 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) { setState(() => _pendingRoomUpdates = roomUpdates); }, 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, ), ], ), ), // Tracking card overlay if (trackedTag != null && placingSensor == null) Positioned( top: 16, left: 16, right: 16, child: _TrackingCard( tag: trackedTag, onStop: () { ref.read(trackedTagProvider.notifier).state = null; }, ), ), // 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: () { final fromSensors = ref.read( sensorPlacementOriginSensorsProvider, ); _cancelPlacement(); if (fromSensors) context.go('/sensors'); }, ), ), ], ], ), ), if (placingSensor == null && mode == FloorPlanMode.edit && unplaced.isNotEmpty) _UnplacedPanel(sensors: unplaced), ], ), ), ); } Future _saveRoomEdits() async { final updates = _pendingRoomUpdates; final originals = _editModeOriginalRooms ?? []; _editModeOriginalRooms = null; _pendingRoomUpdates = null; // Optimistic: switch to view immediately for responsive UX. ref.read(floorPlanModeProvider.notifier).state = FloorPlanMode.view; _editorKey.currentState?.setMode(FloorPlanMode.view); if (updates != null) { final floor = ref.read(floorProvider).valueOrNull; if (floor != null) { final repo = ref.read(floorRepositoryProvider); try { for (final r in updates) { final orig = originals.where((o) => o.id == r.id).firstOrNull; if (orig == null || orig != r) { await repo.updateRoom( floor.id, r.id, x: r.x, y: r.y, name: r.name, width: r.width, height: r.height, ); } } } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Failed to save floor plan changes'), ), ); _editorKey.currentState?.loadFloorPlan(originals); } } finally { ref.invalidate(roomsProvider); } } } } void _discardRoomEdits() { final original = _editModeOriginalRooms; _editModeOriginalRooms = null; _pendingRoomUpdates = null; if (original != null) { _editorKey.currentState?.loadFloorPlan(original); } ref.read(floorPlanModeProvider.notifier).state = FloorPlanMode.view; _editorKey.currentState?.setMode(FloorPlanMode.view); } void _showRoomEditSheet(int roomId) { final room = _pendingRoomUpdates?.where((r) => r.id == roomId).firstOrNull ?? _editModeOriginalRooms?.where((r) => r.id == roomId).firstOrNull; if (room == null) return; showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, builder: (_) => RoomEditSheet( room: room, onSaved: (name, width, height) { final current = _pendingRoomUpdates ?? (ref.read(roomsProvider).valueOrNull ?? []); setState(() { _pendingRoomUpdates = current.map((r) { if (r.id != roomId) return r; return Room( id: r.id, name: name, floorId: r.floorId, x: r.x, y: r.y, width: width, height: height, ); }).toList(); }); _editorKey.currentState?.loadFloorPlan(_pendingRoomUpdates ?? []); }, ), ); } 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 _TrackingCard extends StatelessWidget { const _TrackingCard({required this.tag, required this.onStop}); final Tag tag; final VoidCallback onStop; @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.label_outline), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(tag.name, style: theme.textTheme.labelLarge), Text( 'Tracking particle cloud', style: theme.textTheme.bodySmall, ), ], ), ), TextButton(onPressed: onStop, child: const Text('Stop')), ], ), ), ); } } 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(), ), ), ], ), ); } }