refactor: move floor plan editor into own widget
This commit is contained in:
+8
-2
@@ -133,6 +133,7 @@ function _buildRoomGroup(room) {
|
|||||||
_dimRoom = room;
|
_dimRoom = room;
|
||||||
_dimGroup = group;
|
_dimGroup = group;
|
||||||
_showDimensions(room, group.x(), group.y());
|
_showDimensions(room, group.x(), group.y());
|
||||||
|
notifyFlutter({ type: 'roomTapped', id: room.id });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,18 +256,22 @@ function _showDimensions(room, lx, ly) {
|
|||||||
dimensionsLayer.add(new Konva.Line({ points: [rx + rw, ry, hx + EXT, ry], stroke: c, strokeWidth: 0.8, listening: false }));
|
dimensionsLayer.add(new Konva.Line({ points: [rx + rw, ry, hx + EXT, ry], stroke: c, strokeWidth: 0.8, listening: false }));
|
||||||
dimensionsLayer.add(new Konva.Line({ points: [rx + rw, ry + rh, hx + EXT, ry + rh], stroke: c, strokeWidth: 0.8, listening: false }));
|
dimensionsLayer.add(new Konva.Line({ points: [rx + rw, ry + rh, hx + EXT, ry + rh], stroke: c, strokeWidth: 0.8, listening: false }));
|
||||||
dimensionsLayer.add(new Konva.Text({
|
dimensionsLayer.add(new Konva.Text({
|
||||||
x: hx + 4, y: ry + rh / 2 - 4,
|
x: hx - 10, y: ry + rh / 2,
|
||||||
|
width: rh, align: 'center',
|
||||||
text: `${room.height.toFixed(1)} m`,
|
text: `${room.height.toFixed(1)} m`,
|
||||||
fontSize: 9, fill: c, listening: false,
|
fontSize: 9, fill: c, listening: false,
|
||||||
|
rotation: -90, offsetX: rh / 2, offsetY: 4.5,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// Inside the room near the right edge
|
// Inside the room near the right edge
|
||||||
const hx = rx + rw - 18;
|
const hx = rx + rw - 18;
|
||||||
dimensionsLayer.add(new Konva.Arrow({ ...arrowCfg, points: [hx, ry + 4, hx, ry + rh - 4] }));
|
dimensionsLayer.add(new Konva.Arrow({ ...arrowCfg, points: [hx, ry + 4, hx, ry + rh - 4] }));
|
||||||
dimensionsLayer.add(new Konva.Text({
|
dimensionsLayer.add(new Konva.Text({
|
||||||
x: hx - 22, y: ry + rh / 2 - 4,
|
x: hx - 10, y: ry + rh / 2,
|
||||||
|
width: rh, align: 'center',
|
||||||
text: `${room.height.toFixed(1)} m`,
|
text: `${room.height.toFixed(1)} m`,
|
||||||
fontSize: 9, fill: c, listening: false,
|
fontSize: 9, fill: c, listening: false,
|
||||||
|
rotation: -90, offsetX: rh / 2, offsetY: 4.5,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +513,7 @@ stage.on('click tap', (e) => {
|
|||||||
if (e.target === stage) {
|
if (e.target === stage) {
|
||||||
_setTooltip(null);
|
_setTooltip(null);
|
||||||
_clearDimensions();
|
_clearDimensions();
|
||||||
|
notifyFlutter({ type: 'selectionCleared' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import '../../../domain/models/server_config.dart';
|
|||||||
import 'localiser_client.dart';
|
import 'localiser_client.dart';
|
||||||
|
|
||||||
class SensorClient extends LocaliserdClient {
|
class SensorClient extends LocaliserdClient {
|
||||||
SensorClient({required ServerConfig config, required String token})
|
SensorClient({required super.config, required String super.token});
|
||||||
: super(config: config, token: token);
|
|
||||||
|
|
||||||
Future<List<dynamic>> getSensors() async =>
|
Future<List<dynamic>> getSensors() async =>
|
||||||
await get('/api/sensors') as List<dynamic>;
|
await get('/api/sensors') as List<dynamic>;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import '../../domain/models/floor_plan_mode.dart';
|
|||||||
import '../../domain/models/sensor.dart';
|
import '../../domain/models/sensor.dart';
|
||||||
import '../../providers.dart';
|
import '../../providers.dart';
|
||||||
import '../ble_provision/ble_provision_sheet.dart';
|
import '../ble_provision/ble_provision_sheet.dart';
|
||||||
import 'widgets/konva_web_view.dart';
|
import 'widgets/floor_plan_editor.dart';
|
||||||
|
import 'widgets/room_edit_sheet.dart';
|
||||||
|
|
||||||
class FloorPlanScreen extends ConsumerStatefulWidget {
|
class FloorPlanScreen extends ConsumerStatefulWidget {
|
||||||
const FloorPlanScreen({super.key});
|
const FloorPlanScreen({super.key});
|
||||||
@@ -16,22 +17,21 @@ class FloorPlanScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
||||||
final _konvaKey = GlobalKey<KonvaWebViewState>();
|
final _editorKey = GlobalKey<FloorPlanEditorState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Push any already-loaded data once the WebView is ready.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncAll());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _syncAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncAll() {
|
void _syncAll() {
|
||||||
ref
|
ref
|
||||||
.read(roomsProvider.future)
|
.read(roomsProvider.future)
|
||||||
.then((rooms) => _konvaKey.currentState?.loadFloorPlan(rooms));
|
.then((rooms) => _editorKey.currentState?.loadFloorPlan(rooms));
|
||||||
ref
|
ref
|
||||||
.read(sensorsProvider.future)
|
.read(sensorsProvider.future)
|
||||||
.then((sensors) => _konvaKey.currentState?.loadSensors(sensors));
|
.then((sensors) => _editorKey.currentState?.loadSensors(sensors));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,21 +40,20 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
|||||||
final roomsAsync = ref.watch(roomsProvider);
|
final roomsAsync = ref.watch(roomsProvider);
|
||||||
final sensorsAsync = ref.watch(sensorsProvider);
|
final sensorsAsync = ref.watch(sensorsProvider);
|
||||||
|
|
||||||
// Push data updates to the WebView whenever providers refresh.
|
|
||||||
ref.listen(roomsProvider, (_, next) {
|
ref.listen(roomsProvider, (_, next) {
|
||||||
next.whenData((r) => _konvaKey.currentState?.loadFloorPlan(r));
|
next.whenData((r) => _editorKey.currentState?.loadFloorPlan(r));
|
||||||
});
|
});
|
||||||
ref.listen(sensorsProvider, (_, next) {
|
ref.listen(sensorsProvider, (_, next) {
|
||||||
next.whenData((s) => _konvaKey.currentState?.loadSensors(s));
|
next.whenData((s) => _editorKey.currentState?.loadSensors(s));
|
||||||
});
|
});
|
||||||
ref.listen(tagPositionsProvider, (_, next) {
|
ref.listen(tagPositionsProvider, (_, next) {
|
||||||
next.whenData((t) => _konvaKey.currentState?.updateTags(t));
|
next.whenData((t) => _editorKey.currentState?.updateTags(t));
|
||||||
});
|
});
|
||||||
ref.listen(particleCloudProvider, (_, next) {
|
ref.listen(particleCloudProvider, (_, next) {
|
||||||
next.whenData((p) => _konvaKey.currentState?.updateParticleCloud(p));
|
next.whenData((p) => _editorKey.currentState?.updateParticleCloud(p));
|
||||||
});
|
});
|
||||||
ref.listen(selectedSensorIdProvider, (_, id) {
|
ref.listen(selectedSensorIdProvider, (_, id) {
|
||||||
_konvaKey.currentState?.highlightSensor(id);
|
_editorKey.currentState?.highlightSensor(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
final unplaced = sensorsAsync.valueOrNull
|
final unplaced = sensorsAsync.valueOrNull
|
||||||
@@ -76,7 +75,7 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
|||||||
? FloorPlanMode.view
|
? FloorPlanMode.view
|
||||||
: FloorPlanMode.edit;
|
: FloorPlanMode.edit;
|
||||||
ref.read(floorPlanModeProvider.notifier).state = next;
|
ref.read(floorPlanModeProvider.notifier).state = next;
|
||||||
_konvaKey.currentState?.setMode(next);
|
_editorKey.currentState?.setMode(next);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -86,8 +85,8 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
KonvaWebView(
|
FloorPlanEditor(
|
||||||
key: _konvaKey,
|
key: _editorKey,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
onSensorTapped: (id) {
|
onSensorTapped: (id) {
|
||||||
ref.read(selectedSensorIdProvider.notifier).state = id;
|
ref.read(selectedSensorIdProvider.notifier).state = id;
|
||||||
@@ -115,6 +114,17 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
|||||||
// drag-closure references and causing snap-back.
|
// drag-closure references and causing snap-back.
|
||||||
},
|
},
|
||||||
onRoomAdded: (x, y) => _showAddRoomDialog(x, y),
|
onRoomAdded: (x, y) => _showAddRoomDialog(x, y),
|
||||||
|
onEditRoomTapped: _showRoomEditSheet,
|
||||||
|
onEditSensorTapped: (sensorId) =>
|
||||||
|
context.push('/floorplan/sensors/$sensorId'),
|
||||||
|
onAddSensorTapped: () => showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useRootNavigator: true,
|
||||||
|
builder: (_) => const BleProvisionSheet(),
|
||||||
|
).then((_) {
|
||||||
|
ref.invalidate(sensorsProvider);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
if (roomsAsync.valueOrNull?.isEmpty ?? false)
|
if (roomsAsync.valueOrNull?.isEmpty ?? false)
|
||||||
Center(
|
Center(
|
||||||
@@ -150,36 +160,23 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
|||||||
_UnplacedPanel(sensors: unplaced),
|
_UnplacedPanel(sensors: unplaced),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: mode == FloorPlanMode.edit
|
|
||||||
? Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
FloatingActionButton.small(
|
|
||||||
heroTag: 'add-room',
|
|
||||||
tooltip: 'Add room',
|
|
||||||
onPressed: () => _konvaKey.currentState?.addRoom(),
|
|
||||||
child: const Icon(Icons.add_home_outlined),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
FloatingActionButton.extended(
|
|
||||||
heroTag: 'add-sensor',
|
|
||||||
icon: const Icon(Icons.bluetooth),
|
|
||||||
label: const Text('Add sensor'),
|
|
||||||
onPressed: () => showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (_) => const BleProvisionSheet(),
|
|
||||||
).then((_) {
|
|
||||||
ref.invalidate(sensorsProvider);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<void>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => RoomEditSheet(room: room, floorId: floor.id),
|
||||||
|
).then((_) => ref.invalidate(roomsProvider));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showAddRoomDialog(double x, double y) async {
|
Future<void> _showAddRoomDialog(double x, double y) async {
|
||||||
final nameCtrl = TextEditingController();
|
final nameCtrl = TextEditingController();
|
||||||
final widthCtrl = TextEditingController(text: '5.0');
|
final widthCtrl = TextEditingController(text: '5.0');
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../domain/models/floor_plan_mode.dart';
|
import '../../../domain/models/floor_plan_mode.dart';
|
||||||
import '../../../providers.dart';
|
import '../../../providers.dart';
|
||||||
import '../../floorplan/widgets/konva_web_view.dart';
|
import '../../floorplan/widgets/floor_plan_editor.dart';
|
||||||
|
|
||||||
class StepFloorPlan extends ConsumerStatefulWidget {
|
class StepFloorPlan extends ConsumerStatefulWidget {
|
||||||
const StepFloorPlan({super.key, required this.onComplete});
|
const StepFloorPlan({super.key, required this.onComplete});
|
||||||
@@ -15,7 +15,7 @@ class StepFloorPlan extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _StepFloorPlanState extends ConsumerState<StepFloorPlan> {
|
class _StepFloorPlanState extends ConsumerState<StepFloorPlan> {
|
||||||
final _konvaKey = GlobalKey<KonvaWebViewState>();
|
final _konvaKey = GlobalKey<FloorPlanEditorState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -42,11 +42,11 @@ class _StepFloorPlanState extends ConsumerState<StepFloorPlan> {
|
|||||||
style: Theme.of(context).textTheme.titleLarge),
|
style: Theme.of(context).textTheme.titleLarge),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: KonvaWebView(
|
child: FloorPlanEditor(
|
||||||
key: _konvaKey,
|
key: _konvaKey,
|
||||||
mode: FloorPlanMode.edit,
|
mode: FloorPlanMode.edit,
|
||||||
onSensorTapped: (_) {},
|
onSensorTapped: (_) {},
|
||||||
onSensorMoved: (_, __, ___, ____) {},
|
onSensorMoved: (_, _, _, _) {},
|
||||||
onRoomsUpdated: (roomUpdates) async {
|
onRoomsUpdated: (roomUpdates) async {
|
||||||
final floor = ref.read(floorProvider).valueOrNull;
|
final floor = ref.read(floorProvider).valueOrNull;
|
||||||
if (floor == null) return;
|
if (floor == null) return;
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class SensorListScreen extends ConsumerWidget {
|
|||||||
onPressed: () => showModalBottomSheet<void>(
|
onPressed: () => showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
useRootNavigator: true,
|
||||||
builder: (_) => const BleProvisionSheet(),
|
builder: (_) => const BleProvisionSheet(),
|
||||||
).then((_) => ref.invalidate(sensorsProvider)),
|
).then((_) => ref.invalidate(sensorsProvider)),
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/floorplan',
|
path: '/floorplan',
|
||||||
builder: (context, state) => const FloorPlanScreen(),
|
builder: (context, state) => const FloorPlanScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'sensors/:id',
|
||||||
|
builder: (context, state) => SensorDetailScreen(
|
||||||
|
sensorId: state.pathParameters['id']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
StatefulShellBranch(routes: [
|
StatefulShellBranch(routes: [
|
||||||
|
|||||||
Reference in New Issue
Block a user