refactor: move floor plan editor into own widget

This commit is contained in:
2026-05-13 12:49:30 +02:00
parent 7bf3aa29ea
commit 4e670e1aba
9 changed files with 448 additions and 218 deletions
@@ -0,0 +1,269 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../domain/models/floor.dart';
import '../../../domain/models/floor_plan_mode.dart';
import '../../../domain/models/tag.dart';
import '../../../domain/models/particle.dart';
import '../../../domain/models/sensor.dart';
class FloorPlanEditor extends StatefulWidget {
const FloorPlanEditor({
super.key,
required this.mode,
required this.onSensorTapped,
required this.onSensorMoved,
this.onRoomsUpdated,
this.onRoomAdded,
this.onEditRoomTapped,
this.onEditSensorTapped,
this.onAddSensorTapped,
});
final FloorPlanMode mode;
final void Function(String sensorId) onSensorTapped;
final void Function(String sensorId, int roomId, double x, double y)
onSensorMoved;
final void Function(List<Room> rooms)? onRoomsUpdated;
final void Function(double x, double y)? onRoomAdded;
final void Function(int roomId)? onEditRoomTapped;
final void Function(String sensorId)? onEditSensorTapped;
final void Function()? onAddSensorTapped;
@override
State<FloorPlanEditor> createState() => FloorPlanEditorState();
}
class FloorPlanEditorState extends State<FloorPlanEditor> {
late final WebViewController _controller;
bool _ready = false;
final _pending = <Future<void> Function()>[];
int? _selectedRoomId;
String? _selectedSensorId;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('FlutterBridge', onMessageReceived: _onMessage)
..setNavigationDelegate(
NavigationDelegate(onPageFinished: (_) => _onPageReady()),
)
..loadFlutterAsset('assets/konva/index.html');
}
void _onPageReady() {
_ready = true;
for (final call in _pending) {
call();
}
_pending.clear();
}
Future<void> _run(Future<void> Function() call) {
if (_ready) return call();
_pending.add(call);
return Future.value();
}
void _onMessage(JavaScriptMessage message) {
final data = jsonDecode(message.message) as Map<String, dynamic>;
switch (data['type'] as String?) {
case 'sensorTapped':
final id = data['id'] as String;
if (widget.mode == FloorPlanMode.edit) {
setState(() {
_selectedSensorId = id;
_selectedRoomId = null;
});
} else {
widget.onSensorTapped(id);
}
case 'sensorMoved':
widget.onSensorMoved(
data['id'] as String,
(data['roomId'] as num).toInt(),
(data['x'] as num).toDouble(),
(data['y'] as num).toDouble(),
);
case 'roomsUpdated':
final rooms = (data['rooms'] as List)
.map((r) => Room.fromJson(r as Map<String, dynamic>))
.toList();
widget.onRoomsUpdated?.call(rooms);
case 'roomAdded':
widget.onRoomAdded?.call(
(data['x'] as num).toDouble(),
(data['y'] as num).toDouble(),
);
case 'roomTapped':
setState(() {
_selectedRoomId = (data['id'] as num).toInt();
_selectedSensorId = null;
});
case 'selectionCleared':
setState(() {
_selectedRoomId = null;
_selectedSensorId = null;
});
}
}
Future<void> loadFloorPlan(List<Room> rooms) {
return _run(() async {
final payload = jsonEncode(
rooms
.map(
(r) => {
'id': r.id,
'name': r.name,
'floor_id': r.floorId,
'x': r.x,
'y': r.y,
'width': r.width,
'height': r.height,
},
)
.toList(),
);
await _controller.runJavaScript(
'window.companion.loadFloorPlan($payload)',
);
});
}
Future<void> loadSensors(List<Sensor> sensors) {
return _run(() async {
final payload = jsonEncode(
sensors
.map(
(s) => {
'id': s.id,
'sensor_id': s.sensorId,
'name': s.name,
'floor_x': s.x,
'floor_y': s.y,
'room_id': s.roomId,
},
)
.toList(),
);
await _controller.runJavaScript('window.companion.loadSensors($payload)');
});
}
Future<void> updateTags(List<TagPosition> positions) {
return _run(() async {
final payload = jsonEncode(
positions
.map(
(p) => {
'tagId': p.tagId,
'roomId': p.roomId,
'name': p.name,
'color': p.color,
},
)
.toList(),
);
await _controller.runJavaScript('window.companion.updateTags($payload)');
});
}
Future<void> updateParticleCloud(List<Particle> particles) {
return _run(() async {
final payload = jsonEncode(
particles.map((p) => {'x': p.x, 'y': p.y, 'weight': p.weight}).toList(),
);
await _controller.runJavaScript('window.companion.updateCloud($payload)');
});
}
Future<void> highlightSensor(String? sensorId) {
return _run(() async {
final id = sensorId == null ? 'null' : jsonEncode(sensorId);
await _controller.runJavaScript('window.companion.highlightSensor($id)');
});
}
Future<void> setMode(FloorPlanMode mode) {
setState(() {
_selectedRoomId = null;
_selectedSensorId = null;
});
return _run(() async {
await _controller.runJavaScript(
'window.companion.setMode("${mode.name}")',
);
});
}
Future<void> addRoom() {
return _run(() async {
await _controller.runJavaScript('window.companion.addRoom()');
});
}
@override
Widget build(BuildContext context) {
final inEditMode = widget.mode == FloorPlanMode.edit;
return Stack(
children: [
WebViewWidget(controller: _controller),
Positioned(
bottom: 16,
right: 16,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (inEditMode) ...[
if (_selectedSensorId != null) ...[
FloatingActionButton.small(
heroTag: 'editor-info-sensor',
tooltip: 'Sensor details',
onPressed: () =>
widget.onEditSensorTapped?.call(_selectedSensorId!),
child: const Icon(Icons.info_outline),
),
const SizedBox(height: 8),
],
if (_selectedRoomId != null) ...[
FloatingActionButton.small(
heroTag: 'editor-edit-room',
tooltip: 'Edit room',
onPressed: () =>
widget.onEditRoomTapped?.call(_selectedRoomId!),
child: const Icon(Icons.edit_outlined),
),
const SizedBox(height: 8),
],
FloatingActionButton.extended(
heroTag: 'editor-add-room',
tooltip: 'Add room',
onPressed: addRoom,
icon: const Icon(Icons.add_home_outlined),
label: const Text('Add room'),
),
const SizedBox(height: 8),
],
FloatingActionButton.extended(
heroTag: 'editor-add-sensor',
icon: const Icon(Icons.bluetooth),
label: const Text('Add sensor'),
onPressed: () => widget.onAddSensorTapped?.call(),
),
],
),
),
],
);
}
}
@@ -1,169 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../domain/models/floor.dart';
import '../../../domain/models/floor_plan_mode.dart';
import '../../../domain/models/tag.dart';
import '../../../domain/models/particle.dart';
import '../../../domain/models/sensor.dart';
class KonvaWebView extends StatefulWidget {
const KonvaWebView({
super.key,
required this.mode,
required this.onSensorTapped,
required this.onSensorMoved,
this.onRoomsUpdated,
this.onRoomAdded,
});
final FloorPlanMode mode;
final void Function(String sensorId) onSensorTapped;
final void Function(String sensorId, int roomId, double x, double y) onSensorMoved;
final void Function(List<Room> rooms)? onRoomsUpdated;
final void Function(double x, double y)? onRoomAdded;
@override
State<KonvaWebView> createState() => KonvaWebViewState();
}
class KonvaWebViewState extends State<KonvaWebView> {
late final WebViewController _controller;
bool _ready = false;
final _pending = <Future<void> Function()>[];
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('FlutterBridge', onMessageReceived: _onMessage)
..setNavigationDelegate(
NavigationDelegate(onPageFinished: (_) => _onPageReady()),
)
..loadFlutterAsset('assets/konva/index.html');
}
void _onPageReady() {
_ready = true;
for (final call in _pending) {
call();
}
_pending.clear();
}
Future<void> _run(Future<void> Function() call) {
if (_ready) return call();
_pending.add(call);
return Future.value();
}
void _onMessage(JavaScriptMessage message) {
final data = jsonDecode(message.message) as Map<String, dynamic>;
switch (data['type'] as String?) {
case 'sensorTapped':
widget.onSensorTapped(data['id'] as String);
case 'sensorMoved':
widget.onSensorMoved(
data['id'] as String,
(data['roomId'] as num).toInt(),
(data['x'] as num).toDouble(),
(data['y'] as num).toDouble(),
);
case 'roomsUpdated':
final rooms = (data['rooms'] as List)
.map((r) => Room.fromJson(r as Map<String, dynamic>))
.toList();
widget.onRoomsUpdated?.call(rooms);
case 'roomAdded':
widget.onRoomAdded?.call(
(data['x'] as num).toDouble(),
(data['y'] as num).toDouble(),
);
}
}
Future<void> loadFloorPlan(List<Room> rooms) {
return _run(() async {
final payload = jsonEncode(rooms
.map((r) => {
'id': r.id,
'name': r.name,
'floor_id': r.floorId,
'x': r.x,
'y': r.y,
'width': r.width,
'height': r.height,
})
.toList());
await _controller
.runJavaScript('window.companion.loadFloorPlan($payload)');
});
}
Future<void> loadSensors(List<Sensor> sensors) {
return _run(() async {
final payload = jsonEncode(sensors
.map((s) => {
'id': s.id,
'sensor_id': s.sensorId,
'name': s.name,
'floor_x': s.x,
'floor_y': s.y,
'room_id': s.roomId,
})
.toList());
await _controller
.runJavaScript('window.companion.loadSensors($payload)');
});
}
Future<void> updateTags(List<TagPosition> positions) {
return _run(() async {
final payload = jsonEncode(positions
.map((p) => {
'tagId': p.tagId,
'roomId': p.roomId,
'name': p.name,
'color': p.color,
})
.toList());
await _controller.runJavaScript('window.companion.updateTags($payload)');
});
}
Future<void> updateParticleCloud(List<Particle> particles) {
return _run(() async {
final payload = jsonEncode(particles
.map((p) => {'x': p.x, 'y': p.y, 'weight': p.weight})
.toList());
await _controller.runJavaScript('window.companion.updateCloud($payload)');
});
}
Future<void> highlightSensor(String? sensorId) {
return _run(() async {
final id = sensorId == null ? 'null' : jsonEncode(sensorId);
await _controller
.runJavaScript('window.companion.highlightSensor($id)');
});
}
Future<void> setMode(FloorPlanMode mode) {
return _run(() async {
await _controller
.runJavaScript('window.companion.setMode("${mode.name}")');
});
}
Future<void> addRoom() {
return _run(() async {
await _controller.runJavaScript('window.companion.addRoom()');
});
}
@override
Widget build(BuildContext context) => WebViewWidget(controller: _controller);
}
@@ -0,0 +1,119 @@
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});
final Room room;
final int floorId;
@override
ConsumerState<RoomEditSheet> createState() => _RoomEditSheetState();
}
class _RoomEditSheetState extends ConsumerState<RoomEditSheet> {
late final TextEditingController _nameCtrl;
late final TextEditingController _widthCtrl;
late final TextEditingController _heightCtrl;
bool _saving = false;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.room.name);
_widthCtrl = TextEditingController(text: widget.room.width.toString());
_heightCtrl = TextEditingController(text: widget.room.height.toString());
}
@override
void dispose() {
_nameCtrl.dispose();
_widthCtrl.dispose();
_heightCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
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);
}
}
@override
Widget build(BuildContext context) {
final insets = MediaQuery.viewInsetsOf(context);
return Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + insets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Edit room', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _widthCtrl,
decoration: const InputDecoration(
labelText: 'Width', suffixText: 'm'),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
textInputAction: TextInputAction.next,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _heightCtrl,
decoration: const InputDecoration(
labelText: 'Height', suffixText: 'm'),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _save(),
),
),
],
),
const SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
],
),
);
}
}