diff --git a/lib/domain/models/floor.dart b/lib/domain/models/floor.dart index 655b3f0..c973b06 100644 --- a/lib/domain/models/floor.dart +++ b/lib/domain/models/floor.dart @@ -48,4 +48,18 @@ class Room { 'width': width, 'height': height, }; + + @override + bool operator ==(Object other) { + if(other is! Room) { + return super == other; + } + + return other.height == height + && other.width == width + && other.name == name + && other.id == id + && other.x == x + && other.y == y; + } } diff --git a/lib/features/floorplan/floor_plan_screen.dart b/lib/features/floorplan/floor_plan_screen.dart index 5709588..a2d2271 100644 --- a/lib/features/floorplan/floor_plan_screen.dart +++ b/lib/features/floorplan/floor_plan_screen.dart @@ -2,6 +2,7 @@ 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'; @@ -21,6 +22,9 @@ class FloorPlanScreen extends ConsumerStatefulWidget { class _FloorPlanScreenState extends ConsumerState { final _editorKey = GlobalKey(); + List? _editModeOriginalRooms; + List? _pendingRoomUpdates; + @override void initState() { super.initState(); @@ -31,12 +35,10 @@ class _FloorPlanScreenState extends ConsumerState { ref .read(roomsProvider.future) .then((rooms) => _editorKey.currentState?.loadFloorPlan(rooms)); - ref - .read(sensorsProvider.future) - .then((sensors) { - _editorKey.currentState?.loadSensors(sensors); - _applyPlacementState(); - }); + ref.read(sensorsProvider.future).then((sensors) { + _editorKey.currentState?.loadSensors(sensors); + _applyPlacementState(); + }); } void _applyPlacementState() { @@ -56,18 +58,17 @@ class _FloorPlanScreenState extends ConsumerState { } Future _confirmPlacement(Sensor sensor) async { - final result = - await _editorKey.currentState?.getPositionAtCenter(); + 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'), - ), + const SnackBar(content: Text('Move the map so the dot is over a room')), ); return; } - await ref.read(sensorRepositoryProvider).placeSensor( + await ref + .read(sensorRepositoryProvider) + .placeSensor( sensor.id, roomId: result.roomId!, x: result.x!, @@ -105,8 +106,10 @@ class _FloorPlanScreenState extends ConsumerState { 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.'), + content: Text( + 'Sensor did not come online in time. ' + 'Check WiFi credentials and try again.', + ), ), ); } @@ -118,7 +121,9 @@ class _FloorPlanScreenState extends ConsumerState { if (trackedTag != null) { ref.listen(particleSnapshotProvider(trackedTag.tagId), (_, next) { next.whenData( - (snap) => _editorKey.currentState?.updateParticleCloud(snap.particles)); + (snap) => + _editorKey.currentState?.updateParticleCloud(snap.particles), + ); }); } ref.listen(trackedTagProvider, (prev, next) { @@ -126,10 +131,8 @@ class _FloorPlanScreenState extends ConsumerState { }); ref.listen(sensorPlacementProvider, (prev, next) => _applyPlacementState()); - final unplaced = sensorsAsync.valueOrNull - ?.where((s) => !s.isPlaced) - .toList() ?? - []; + final unplaced = + sensorsAsync.valueOrNull?.where((s) => !s.isPlaced).toList() ?? []; return PopScope( // While placing, intercept back: cancel placement and go to sensors list. @@ -151,6 +154,11 @@ class _FloorPlanScreenState extends ConsumerState { 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); @@ -165,16 +173,29 @@ class _FloorPlanScreenState extends ConsumerState { _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); - }, - ), + ] 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); + }, + ), + ], ], ], ), @@ -193,61 +214,45 @@ class _FloorPlanScreenState extends ConsumerState { final id = int.parse(sensorId); await ref .read(sensorRepositoryProvider) - .placeSensor( - id, - roomId: roomId, - x: x, - y: y, - ); + .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. + 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); - }), + 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), + 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), + 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 + style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: Colors.grey.shade500), textAlign: TextAlign.center, ), @@ -278,8 +283,9 @@ class _FloorPlanScreenState extends ConsumerState { sensor: placingSensor, onPlace: () => _confirmPlacement(placingSensor), onCancel: () { - final fromSensors = - ref.read(sensorPlacementOriginSensorsProvider); + final fromSensors = ref.read( + sensorPlacementOriginSensorsProvider, + ); _cancelPlacement(); if (fromSensors) context.go('/sensors'); }, @@ -299,18 +305,94 @@ class _FloorPlanScreenState extends ConsumerState { ); } + 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 rooms = ref.read(roomsProvider).valueOrNull ?? []; - final room = rooms.where((r) => r.id == roomId).firstOrNull; + final room = + _pendingRoomUpdates?.where((r) => r.id == roomId).firstOrNull ?? + _editModeOriginalRooms?.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)); + 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 { @@ -338,9 +420,12 @@ class _FloorPlanScreenState extends ConsumerState { child: TextField( controller: widthCtrl, decoration: const InputDecoration( - labelText: 'Width', suffixText: 'm'), + labelText: 'Width', + suffixText: 'm', + ), keyboardType: const TextInputType.numberWithOptions( - decimal: true), + decimal: true, + ), ), ), const SizedBox(width: 8), @@ -348,9 +433,12 @@ class _FloorPlanScreenState extends ConsumerState { child: TextField( controller: heightCtrl, decoration: const InputDecoration( - labelText: 'Height', suffixText: 'm'), + labelText: 'Height', + suffixText: 'm', + ), keyboardType: const TextInputType.numberWithOptions( - decimal: true), + decimal: true, + ), ), ), ], @@ -385,7 +473,9 @@ class _FloorPlanScreenState extends ConsumerState { ref.invalidate(floorProvider); } - await ref.read(floorRepositoryProvider).createRoom( + await ref + .read(floorRepositoryProvider) + .createRoom( floor.id, name: name, width: width, @@ -438,8 +528,10 @@ class _TrackingCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text(tag.name, style: theme.textTheme.labelLarge), - Text('Tracking particle cloud', - style: theme.textTheme.bodySmall), + Text( + 'Tracking particle cloud', + style: theme.textTheme.bodySmall, + ), ], ), ), @@ -478,10 +570,11 @@ class _PlacementCard extends StatelessWidget { 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), + Text(sensor.displayName, style: theme.textTheme.labelLarge), + Text( + 'Pan to position, then tap Place', + style: theme.textTheme.bodySmall, + ), ], ), ), @@ -509,8 +602,10 @@ class _UnplacedPanel extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('Unplaced sensors', - style: Theme.of(context).textTheme.labelSmall), + Text( + 'Unplaced sensors', + style: Theme.of(context).textTheme.labelSmall, + ), const SizedBox(height: 4), SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -520,11 +615,12 @@ class _UnplacedPanel extends StatelessWidget { (s) => Padding( padding: const EdgeInsets.only(right: 8), child: ActionChip( - avatar: const Icon(Icons.sensors_off_outlined, - size: 16), + avatar: const Icon( + Icons.sensors_off_outlined, + size: 16, + ), label: Text(s.displayName), - onPressed: () => - showSensorDetailSheet(context, s.id), + onPressed: () => showSensorDetailSheet(context, s.id), ), ), ) diff --git a/lib/features/floorplan/widgets/room_edit_sheet.dart b/lib/features/floorplan/widgets/room_edit_sheet.dart index 98236ae..5d091b1 100644 --- a/lib/features/floorplan/widgets/room_edit_sheet.dart +++ b/lib/features/floorplan/widgets/room_edit_sheet.dart @@ -1,24 +1,25 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../domain/models/floor.dart'; -import '../../../providers.dart'; -class RoomEditSheet extends ConsumerStatefulWidget { - const RoomEditSheet({super.key, required this.room, required this.floorId}); +class RoomEditSheet extends StatefulWidget { + const RoomEditSheet({ + super.key, + required this.room, + required this.onSaved, + }); final Room room; - final int floorId; + final void Function(String name, double width, double height) onSaved; @override - ConsumerState createState() => _RoomEditSheetState(); + State createState() => _RoomEditSheetState(); } -class _RoomEditSheetState extends ConsumerState { +class _RoomEditSheetState extends State { late final TextEditingController _nameCtrl; late final TextEditingController _widthCtrl; late final TextEditingController _heightCtrl; - bool _saving = false; @override void initState() { @@ -36,25 +37,14 @@ class _RoomEditSheetState extends ConsumerState { super.dispose(); } - Future _save() async { + void _save() { final name = _nameCtrl.text.trim(); final width = double.tryParse(_widthCtrl.text); final height = double.tryParse(_heightCtrl.text); if (name.isEmpty || width == null || height == null) return; - setState(() => _saving = true); - try { - await ref.read(floorRepositoryProvider).updateRoom( - widget.floorId, - widget.room.id, - name: name, - width: width, - height: height, - ); - if (mounted) Navigator.of(context).pop(); - } finally { - if (mounted) setState(() => _saving = false); - } + widget.onSaved(name, width, height); + Navigator.of(context).pop(); } @override @@ -103,14 +93,8 @@ class _RoomEditSheetState extends ConsumerState { ), const SizedBox(height: 24), FilledButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Save'), + onPressed: _save, + child: const Text('Save'), ), ], ),