feat: revamp sensor add flow
This commit is contained in:
+3
-1
@@ -377,6 +377,7 @@ function renderSensors() {
|
|||||||
if (!existing.isDragging()) existing.position({ x: absX, y: absY });
|
if (!existing.isDragging()) existing.position({ x: absX, y: absY });
|
||||||
existing.draggable(mode === 'sensorMove');
|
existing.draggable(mode === 'sensorMove');
|
||||||
existing.findOne('Text')?.text(label);
|
existing.findOne('Text')?.text(label);
|
||||||
|
existing.findOne('.sensor-dot')?.fill(sensor.confirmed ? '#1565C0' : '#9E9E9E');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,8 +395,9 @@ function _buildSensorGroup(sensor, absX, absY, label) {
|
|||||||
|
|
||||||
// Visible dot — hitFunc expands the touch target without changing appearance.
|
// Visible dot — hitFunc expands the touch target without changing appearance.
|
||||||
group.add(new Konva.Circle({
|
group.add(new Konva.Circle({
|
||||||
|
name: 'sensor-dot',
|
||||||
radius: 8,
|
radius: 8,
|
||||||
fill: '#1565C0',
|
fill: sensor.confirmed ? '#1565C0' : '#9E9E9E',
|
||||||
stroke: '#ffffff',
|
stroke: '#ffffff',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
hitFunc(ctx, shape) {
|
hitFunc(ctx, shape) {
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import '../../domain/models/sensor.dart';
|
import '../../domain/models/sensor.dart';
|
||||||
|
import '../sources/localiser/realtime_data_client.dart';
|
||||||
import '../sources/localiser/sensor_client.dart';
|
import '../sources/localiser/sensor_client.dart';
|
||||||
import 'sensor_repository.dart';
|
import 'sensor_repository.dart';
|
||||||
|
|
||||||
class PhoenixSensorRepository implements SensorRepository {
|
class PhoenixSensorRepository implements SensorRepository {
|
||||||
const PhoenixSensorRepository({required this.client});
|
const PhoenixSensorRepository({
|
||||||
|
required this.client,
|
||||||
|
required this.realtime,
|
||||||
|
});
|
||||||
|
|
||||||
final SensorClient client;
|
final SensorClient client;
|
||||||
|
final RealtimeDataClient realtime;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Sensor> createSensor(String sensorId, {String? name}) async =>
|
||||||
|
Sensor.fromJson(await client.createSensor(sensorId, name: name));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Sensor>> getSensors() async {
|
Future<List<Sensor>> getSensors() async {
|
||||||
@@ -42,4 +51,9 @@ class PhoenixSensorRepository implements SensorRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Sensor> unplaceSensor(int id) async =>
|
Future<Sensor> unplaceSensor(int id) async =>
|
||||||
Sensor.fromJson(await client.unplaceSensor(id));
|
Sensor.fromJson(await client.unplaceSensor(id));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Map<String, dynamic>> sensorEvents() => realtime
|
||||||
|
.channelMessages('sensors')
|
||||||
|
.map((m) => {'event': m.event, ...m.payload});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import '../../domain/models/sensor.dart';
|
import '../../domain/models/sensor.dart';
|
||||||
|
|
||||||
abstract class SensorRepository {
|
abstract class SensorRepository {
|
||||||
|
Future<Sensor> createSensor(String sensorId, {String? name});
|
||||||
Future<List<Sensor>> getSensors();
|
Future<List<Sensor>> getSensors();
|
||||||
Future<List<Sensor>> getUnplacedSensors();
|
Future<List<Sensor>> getUnplacedSensors();
|
||||||
Future<Sensor> getSensor(int id);
|
Future<Sensor> getSensor(int id);
|
||||||
@@ -9,4 +10,8 @@ abstract class SensorRepository {
|
|||||||
Future<Sensor> placeSensor(int id,
|
Future<Sensor> placeSensor(int id,
|
||||||
{required int roomId, required double x, required double y});
|
{required int roomId, required double x, required double y});
|
||||||
Future<Sensor> unplaceSensor(int id);
|
Future<Sensor> unplaceSensor(int id);
|
||||||
|
|
||||||
|
/// Stream of raw SensorsChannel messages. Each map contains an `event` key
|
||||||
|
/// (`sensor_announced` or `sensor_enrollment_timeout`) plus the payload.
|
||||||
|
Stream<Map<String, dynamic>> sensorEvents();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,26 +31,61 @@ class BleProvisioner {
|
|||||||
|
|
||||||
BluetoothDevice? _connectedDevice;
|
BluetoothDevice? _connectedDevice;
|
||||||
|
|
||||||
Stream<BleScanResult> scan() async* {
|
// Continuously scans for nearby ESP32 sensors, restarting after each
|
||||||
await _requestScanPermissions();
|
// 15-second window, until the returned stream is cancelled.
|
||||||
|
Stream<BleScanResult> scan() {
|
||||||
|
StreamSubscription<List<ScanResult>>? resultsSub;
|
||||||
|
StreamSubscription<bool>? stateSub;
|
||||||
|
bool started = false;
|
||||||
|
late StreamController<BleScanResult> controller;
|
||||||
|
|
||||||
|
Future<void> startScan() async {
|
||||||
|
if (controller.isClosed) return;
|
||||||
|
started = true;
|
||||||
|
try {
|
||||||
await FlutterBluePlus.startScan(
|
await FlutterBluePlus.startScan(
|
||||||
withServices: [Guid(_serviceUuid)],
|
withServices: [Guid(_serviceUuid)],
|
||||||
timeout: const Duration(seconds: 30),
|
|
||||||
);
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
await for (final results in FlutterBluePlus.scanResults) {
|
controller = StreamController<BleScanResult>(
|
||||||
|
onListen: () async {
|
||||||
|
try {
|
||||||
|
await _requestScanPermissions();
|
||||||
|
} catch (e) {
|
||||||
|
controller.addError(e);
|
||||||
|
await controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsSub = FlutterBluePlus.scanResults.listen((results) {
|
||||||
for (final r in results) {
|
for (final r in results) {
|
||||||
final name = r.device.platformName;
|
final name = r.device.platformName;
|
||||||
if (name.startsWith('anchor_')) {
|
if (name.startsWith('anchor_') && !controller.isClosed) {
|
||||||
yield BleScanResult(
|
controller.add(BleScanResult(
|
||||||
deviceId: r.device.remoteId.str,
|
deviceId: r.device.remoteId.str,
|
||||||
name: name,
|
name: name,
|
||||||
rssi: r.rssi,
|
rssi: r.rssi,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!FlutterBluePlus.isScanningNow) {
|
||||||
|
await startScan();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onCancel: () {
|
||||||
|
resultsSub?.cancel();
|
||||||
|
stateSub?.cancel();
|
||||||
|
FlutterBluePlus.stopScan();
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
return controller.stream;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopScan() => FlutterBluePlus.stopScan();
|
Future<void> stopScan() => FlutterBluePlus.stopScan();
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ class RealtimeDataClient {
|
|||||||
.map((msg) => msg.payload ?? const {});
|
.map((msg) => msg.payload ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [channel], but includes the Phoenix event name in each emission.
|
||||||
|
Stream<({String event, Map<String, dynamic> payload})> channelMessages(
|
||||||
|
String topic, {
|
||||||
|
Map<String, dynamic> params = const {},
|
||||||
|
}) {
|
||||||
|
final socket = _socket;
|
||||||
|
if (socket == null) throw StateError('RealtimeDataClient not connected');
|
||||||
|
|
||||||
|
final ch = _channels.putIfAbsent(
|
||||||
|
topic,
|
||||||
|
() {
|
||||||
|
final c = socket.addChannel(topic: topic, parameters: params);
|
||||||
|
c.join();
|
||||||
|
return c;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return ch.messages
|
||||||
|
.where((msg) => msg.event.value != 'phx_reply')
|
||||||
|
.map((msg) => (event: msg.event.value, payload: msg.payload ?? const {}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Pushes [event] on [topic] and waits for the server reply.
|
/// Pushes [event] on [topic] and waits for the server reply.
|
||||||
/// The channel must have been joined first via [channel].
|
/// The channel must have been joined first via [channel].
|
||||||
Future<Map<String, dynamic>> push(
|
Future<Map<String, dynamic>> push(
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ class SensorClient extends LocaliserdClient {
|
|||||||
Future<Map<String, dynamic>> unplaceSensor(int id) async =>
|
Future<Map<String, dynamic>> unplaceSensor(int id) async =>
|
||||||
await deleteBody('/api/sensors/$id/place') as Map<String, dynamic>;
|
await deleteBody('/api/sensors/$id/place') as Map<String, dynamic>;
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> createSensor(String sensorId,
|
||||||
|
{String? name}) async =>
|
||||||
|
await post('/api/sensors', {
|
||||||
|
'sensor_id': sensorId,
|
||||||
|
if (name != null) 'name': name,
|
||||||
|
}) as Map<String, dynamic>;
|
||||||
|
|
||||||
Future<Map<String, dynamic>> startCalibration(
|
Future<Map<String, dynamic>> startCalibration(
|
||||||
int id, double referenceDistance) async =>
|
int id, double referenceDistance) async =>
|
||||||
await post('/api/sensors/$id/calibration/start',
|
await post('/api/sensors/$id/calibration/start',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class Sensor {
|
|||||||
const Sensor({
|
const Sensor({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.sensorId,
|
required this.sensorId,
|
||||||
|
required this.confirmed,
|
||||||
this.name,
|
this.name,
|
||||||
this.roomId,
|
this.roomId,
|
||||||
this.x,
|
this.x,
|
||||||
@@ -10,6 +11,7 @@ class Sensor {
|
|||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
final String sensorId; // BLE MAC / provisioning device ID
|
final String sensorId; // BLE MAC / provisioning device ID
|
||||||
|
final bool confirmed; // true once seen on MQTT
|
||||||
final String? name; // human-readable label
|
final String? name; // human-readable label
|
||||||
final int? roomId;
|
final int? roomId;
|
||||||
final double? x; // room relative
|
final double? x; // room relative
|
||||||
@@ -21,6 +23,7 @@ class Sensor {
|
|||||||
factory Sensor.fromJson(Map<String, dynamic> json) => Sensor(
|
factory Sensor.fromJson(Map<String, dynamic> json) => Sensor(
|
||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
sensorId: json['sensor_id'] as String,
|
sensorId: json['sensor_id'] as String,
|
||||||
|
confirmed: json['confirmed'] as bool,
|
||||||
name: json['name'] as String?,
|
name: json['name'] as String?,
|
||||||
roomId: json['room_id'] as int?,
|
roomId: json['room_id'] as int?,
|
||||||
x: (json['x'] as num?)?.toDouble(),
|
x: (json['x'] as num?)?.toDouble(),
|
||||||
@@ -37,6 +40,7 @@ class Sensor {
|
|||||||
Sensor(
|
Sensor(
|
||||||
id: id,
|
id: id,
|
||||||
sensorId: sensorId,
|
sensorId: sensorId,
|
||||||
|
confirmed: confirmed,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
roomId: roomId ?? this.roomId,
|
roomId: roomId ?? this.roomId,
|
||||||
x: x ?? this.x,
|
x: x ?? this.x,
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../data/sources/ble/ble_provisioner.dart';
|
import '../../data/sources/ble/ble_provisioner.dart';
|
||||||
import '../../providers.dart';
|
import '../../providers.dart';
|
||||||
|
|
||||||
enum _Step { provision, done }
|
enum _Step { scan, configure, done }
|
||||||
|
|
||||||
// Shared bottom sheet used by onboarding and the main sensor screens.
|
// Shared bottom sheet used by onboarding and the main sensor screens.
|
||||||
// Flow: scan → select device → enter WiFi credentials → provision → place on map.
|
// Flow: scan -> select device (slides to configure) -> provision -> done.
|
||||||
class BleProvisionSheet extends ConsumerStatefulWidget {
|
class BleProvisionSheet extends ConsumerStatefulWidget {
|
||||||
const BleProvisionSheet({super.key});
|
const BleProvisionSheet({super.key});
|
||||||
|
|
||||||
@@ -20,10 +20,12 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
|||||||
final _provisioner = BleProvisioner();
|
final _provisioner = BleProvisioner();
|
||||||
final _ssidController = TextEditingController();
|
final _ssidController = TextEditingController();
|
||||||
final _wifiPasswordController = TextEditingController();
|
final _wifiPasswordController = TextEditingController();
|
||||||
|
late final PageController _pageController;
|
||||||
|
|
||||||
late Stream<BleScanResult> _scanStream;
|
late Stream<BleScanResult> _scanStream;
|
||||||
|
int _scanGeneration = 0;
|
||||||
|
|
||||||
_Step _step = _Step.provision;
|
_Step _step = _Step.scan;
|
||||||
BleScanResult? _selected;
|
BleScanResult? _selected;
|
||||||
bool _provisioning = false;
|
bool _provisioning = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
@@ -31,6 +33,7 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_pageController = PageController();
|
||||||
_scanStream = _provisioner.scan();
|
_scanStream = _provisioner.scan();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
|||||||
_provisioner.dispose();
|
_provisioner.dispose();
|
||||||
_ssidController.dispose();
|
_ssidController.dispose();
|
||||||
_wifiPasswordController.dispose();
|
_wifiPasswordController.dispose();
|
||||||
|
_pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +61,17 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
|||||||
mqttHost: mqttBroker?.host,
|
mqttHost: mqttBroker?.host,
|
||||||
mqttPort: mqttBroker?.port,
|
mqttPort: mqttBroker?.port,
|
||||||
);
|
);
|
||||||
if (mounted) setState(() => _step = _Step.done);
|
await ref
|
||||||
|
.read(sensorRepositoryProvider)
|
||||||
|
.createSensor(_selected!.name, name: _selected!.name);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _step = _Step.done);
|
||||||
|
_pageController.animateToPage(
|
||||||
|
2,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _error = e.toString());
|
if (mounted) setState(() => _error = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
@@ -65,15 +79,46 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _selectDevice(BleScanResult r) {
|
||||||
|
setState(() {
|
||||||
|
_selected = r;
|
||||||
|
_step = _Step.configure;
|
||||||
|
});
|
||||||
|
_pageController.animateToPage(
|
||||||
|
1,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goBack() {
|
||||||
|
setState(() {
|
||||||
|
_step = _Step.scan;
|
||||||
|
_selected = null;
|
||||||
|
_error = null;
|
||||||
|
_scanGeneration++;
|
||||||
|
});
|
||||||
|
_pageController.animateToPage(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _reset() {
|
void _reset() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_step = _Step.provision;
|
_step = _Step.scan;
|
||||||
_selected = null;
|
_selected = null;
|
||||||
_error = null;
|
_error = null;
|
||||||
_ssidController.clear();
|
_ssidController.clear();
|
||||||
_wifiPasswordController.clear();
|
_wifiPasswordController.clear();
|
||||||
_scanStream = _provisioner.scan();
|
_scanGeneration++;
|
||||||
});
|
});
|
||||||
|
_pageController.animateToPage(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -83,14 +128,66 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
|||||||
initialChildSize: 0.6,
|
initialChildSize: 0.6,
|
||||||
maxChildSize: 0.9,
|
maxChildSize: 0.9,
|
||||||
builder: (context, scrollController) => Padding(
|
builder: (context, scrollController) => Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
24,
|
child: Column(
|
||||||
16,
|
children: [
|
||||||
24,
|
if (_step != _Step.done) ...[
|
||||||
MediaQuery.of(context).viewInsets.bottom + 24,
|
Padding(
|
||||||
|
padding: const .fromLTRB(12, 12, 12, 0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const .fromLTRB(0, 0, 12, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (_step == _Step.configure)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: _goBack,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 0, height: 48),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const .only(left: 12),
|
||||||
|
child: Text(
|
||||||
|
_step == _Step.scan
|
||||||
|
? 'Add sensor'
|
||||||
|
: (_selected?.name ?? 'Configure'),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
child: _step == _Step.done
|
),
|
||||||
? _DoneStep(
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(height: 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: PageView(
|
||||||
|
controller: _pageController,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
children:
|
||||||
|
[
|
||||||
|
_ScanPage(
|
||||||
|
scrollController: scrollController,
|
||||||
|
scanStream: _scanStream,
|
||||||
|
generation: _scanGeneration,
|
||||||
|
selected: _selected,
|
||||||
|
onSelected: _selectDevice,
|
||||||
|
),
|
||||||
|
_ConfigurePage(
|
||||||
|
ssidController: _ssidController,
|
||||||
|
wifiPasswordController: _wifiPasswordController,
|
||||||
|
provisioning: _provisioning,
|
||||||
|
error: _error,
|
||||||
|
onProvision: _provision,
|
||||||
|
),
|
||||||
|
_DoneStep(
|
||||||
onGoToFloorPlan: () {
|
onGoToFloorPlan: () {
|
||||||
final router = GoRouter.of(context);
|
final router = GoRouter.of(context);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -98,29 +195,118 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
|||||||
},
|
},
|
||||||
onAddAnother: _reset,
|
onAddAnother: _reset,
|
||||||
onDone: () => Navigator.of(context).pop(),
|
onDone: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map(
|
||||||
|
(page) => Padding(
|
||||||
|
padding: const .symmetric(horizontal: 24),
|
||||||
|
child: page,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: _ProvisionStep(
|
.toList(),
|
||||||
scrollController: scrollController,
|
),
|
||||||
scanStream: _scanStream,
|
),
|
||||||
selected: _selected,
|
],
|
||||||
onSelected: (r) => setState(() => _selected = r),
|
|
||||||
ssidController: _ssidController,
|
|
||||||
wifiPasswordController: _wifiPasswordController,
|
|
||||||
provisioning: _provisioning,
|
|
||||||
error: _error,
|
|
||||||
onProvision: _provision,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProvisionStep extends StatefulWidget {
|
class _ScanPage extends StatefulWidget {
|
||||||
const _ProvisionStep({
|
const _ScanPage({
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
required this.scanStream,
|
required this.scanStream,
|
||||||
|
required this.generation,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final Stream<BleScanResult> scanStream;
|
||||||
|
final int generation;
|
||||||
|
final BleScanResult? selected;
|
||||||
|
final ValueChanged<BleScanResult> onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ScanPage> createState() => _ScanPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanPageState extends State<_ScanPage>
|
||||||
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
final _discovered = <String, BleScanResult>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_ScanPage old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.generation != widget.generation ||
|
||||||
|
old.scanStream != widget.scanStream) {
|
||||||
|
_discovered.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
return StreamBuilder<BleScanResult>(
|
||||||
|
stream: widget.scanStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
_discovered[snapshot.data!.deviceId] = snapshot.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
padding: const EdgeInsets.only(top: 16, bottom: 24),
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Nearby sensor devices',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const SizedBox.square(
|
||||||
|
dimension: 12,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (_discovered.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text(
|
||||||
|
'No devices found yet…',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
..._discovered.values.map((r) {
|
||||||
|
final selected = widget.selected?.deviceId == r.deviceId;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.sensors),
|
||||||
|
title: Text(r.name),
|
||||||
|
subtitle: Text('${r.rssi} dBm'),
|
||||||
|
selected: selected,
|
||||||
|
onTap: () => widget.onSelected(r),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConfigurePage extends StatefulWidget {
|
||||||
|
const _ConfigurePage({
|
||||||
required this.ssidController,
|
required this.ssidController,
|
||||||
required this.wifiPasswordController,
|
required this.wifiPasswordController,
|
||||||
required this.provisioning,
|
required this.provisioning,
|
||||||
@@ -128,10 +314,6 @@ class _ProvisionStep extends StatefulWidget {
|
|||||||
required this.onProvision,
|
required this.onProvision,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ScrollController scrollController;
|
|
||||||
final Stream<BleScanResult> scanStream;
|
|
||||||
final BleScanResult? selected;
|
|
||||||
final ValueChanged<BleScanResult> onSelected;
|
|
||||||
final TextEditingController ssidController;
|
final TextEditingController ssidController;
|
||||||
final TextEditingController wifiPasswordController;
|
final TextEditingController wifiPasswordController;
|
||||||
final bool provisioning;
|
final bool provisioning;
|
||||||
@@ -139,107 +321,83 @@ class _ProvisionStep extends StatefulWidget {
|
|||||||
final VoidCallback onProvision;
|
final VoidCallback onProvision;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ProvisionStep> createState() => _ProvisionStepState();
|
State<_ConfigurePage> createState() => _ConfigurePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProvisionStepState extends State<_ProvisionStep> {
|
class _ConfigurePageState extends State<_ConfigurePage> {
|
||||||
final _discovered = <String, BleScanResult>{};
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView(
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
controller: widget.scrollController,
|
// scrollPadding ensures the field scrolls fully clear of the keyboard.
|
||||||
|
final fieldScrollPadding = EdgeInsets.only(bottom: bottomInset + 24);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.only(top: 16, bottom: bottomInset + 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('Add sensor', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
Text('Nearby ESP32 devices',
|
|
||||||
style: Theme.of(context).textTheme.labelLarge),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
StreamBuilder<BleScanResult>(
|
|
||||||
stream: widget.scanStream,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
final r = snapshot.data!;
|
|
||||||
_discovered[r.deviceId] = r;
|
|
||||||
}
|
|
||||||
if (_discovered.isEmpty) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox.square(
|
|
||||||
dimension: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2)),
|
|
||||||
SizedBox(width: 12),
|
|
||||||
Text('Scanning…'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Column(
|
|
||||||
children: _discovered.values.map((r) {
|
|
||||||
final selected = widget.selected?.deviceId == r.deviceId;
|
|
||||||
return ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.sensors),
|
|
||||||
title: Text(r.name),
|
|
||||||
subtitle: Text('${r.rssi} dBm'),
|
|
||||||
trailing: selected
|
|
||||||
? const Icon(Icons.check_circle,
|
|
||||||
color: Colors.green)
|
|
||||||
: null,
|
|
||||||
selected: selected,
|
|
||||||
onTap: () => widget.onSelected(r),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
if (widget.selected != null) ...[
|
|
||||||
Text('Selected: ${widget.selected!.name}'),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: widget.ssidController,
|
controller: widget.ssidController,
|
||||||
|
scrollPadding: fieldScrollPadding,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'WiFi SSID',
|
labelText: 'WiFi SSID',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
|
textInputAction: .next,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: widget.wifiPasswordController,
|
controller: widget.wifiPasswordController,
|
||||||
decoration: const InputDecoration(
|
scrollPadding: fieldScrollPadding,
|
||||||
labelText: 'WiFi password',
|
decoration: InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
labelText: 'WiFi Password',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||||
),
|
),
|
||||||
obscureText: true,
|
onPressed: () =>
|
||||||
|
setState(() => _obscurePassword = !_obscurePassword),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (widget.ssidController.text.trim().isNotEmpty) {
|
||||||
|
widget.onProvision();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
|
||||||
|
|
||||||
if (widget.error != null)
|
if (widget.error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Text(widget.error!,
|
child: Text(
|
||||||
style:
|
widget.error!,
|
||||||
TextStyle(color: Theme.of(context).colorScheme.error)),
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
FilledButton(
|
ListenableBuilder(
|
||||||
onPressed:
|
listenable: widget.ssidController,
|
||||||
(widget.selected == null || widget.provisioning) ? null : widget.onProvision,
|
builder: (context, _) {
|
||||||
|
final canProvision = widget.ssidController.text.trim().isNotEmpty;
|
||||||
|
return FilledButton(
|
||||||
|
onPressed: (widget.provisioning || !canProvision)
|
||||||
|
? null
|
||||||
|
: widget.onProvision,
|
||||||
child: widget.provisioning
|
child: widget.provisioning
|
||||||
? const SizedBox.square(
|
? const SizedBox.square(
|
||||||
dimension: 20,
|
dimension: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2))
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
: const Text('Provision & add'),
|
: const Text('Provision & add'),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,7 +415,9 @@ class _DoneStep extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -288,11 +448,9 @@ class _DoneStep extends StatelessWidget {
|
|||||||
onPressed: onAddAnother,
|
onPressed: onAddAnother,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextButton(
|
TextButton(onPressed: onDone, child: const Text('Done')),
|
||||||
onPressed: onDone,
|
|
||||||
child: const Text('Done'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,21 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
|||||||
ref.listen(sensorsProvider, (_, next) {
|
ref.listen(sensorsProvider, (_, next) {
|
||||||
next.whenData((s) => _editorKey.currentState?.loadSensors(s));
|
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) {
|
ref.listen(tagPositionsProvider, (_, next) {
|
||||||
next.whenData((t) => _editorKey.currentState?.updateTags(t));
|
next.whenData((t) => _editorKey.currentState?.updateTags(t));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
|
|||||||
'floor_x': s.x,
|
'floor_x': s.x,
|
||||||
'floor_y': s.y,
|
'floor_y': s.y,
|
||||||
'room_id': s.roomId,
|
'room_id': s.roomId,
|
||||||
|
'confirmed': s.confirmed,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ class SensorListScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final sensors = ref.watch(sensorsProvider);
|
final sensors = ref.watch(sensorsProvider);
|
||||||
|
|
||||||
|
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.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sensors')),
|
appBar: AppBar(title: const Text('Sensors')),
|
||||||
body: Column(
|
body: Column(
|
||||||
@@ -35,16 +51,12 @@ class SensorListScreen extends ConsumerWidget {
|
|||||||
if (unplaced.isNotEmpty) ...[
|
if (unplaced.isNotEmpty) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
label: 'Unplaced', count: unplaced.length),
|
label: 'Unplaced', count: unplaced.length),
|
||||||
...unplaced.map((s) => _SensorTile(
|
...unplaced.map((s) => _SensorTile(sensor: s)),
|
||||||
sensor: s,
|
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
if (placed.isNotEmpty) ...[
|
if (placed.isNotEmpty) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
label: 'Placed', count: placed.length),
|
label: 'Placed', count: placed.length),
|
||||||
...placed.map((s) => _SensorTile(
|
...placed.map((s) => _SensorTile(sensor: s)),
|
||||||
sensor: s,
|
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -87,22 +99,30 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SensorTile extends StatelessWidget {
|
class _SensorTile extends StatelessWidget {
|
||||||
const _SensorTile({required this.sensor, this.isSelected = false});
|
const _SensorTile({required this.sensor});
|
||||||
|
|
||||||
final Sensor sensor;
|
final Sensor sensor;
|
||||||
final bool isSelected;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
selected: isSelected,
|
|
||||||
leading: Icon(sensor.isPlaced
|
leading: Icon(sensor.isPlaced
|
||||||
? Icons.sensors
|
? Icons.sensors
|
||||||
: Icons.sensors_off_outlined),
|
: Icons.sensors_off_outlined),
|
||||||
title: Text(sensor.displayName),
|
title: Text(sensor.displayName),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
sensor.isPlaced ? 'Placed' : 'Not placed on floor plan'),
|
sensor.isPlaced ? 'Placed' : 'Not placed on floor plan'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
textColor: sensor.confirmed ? null : Colors.grey,
|
||||||
|
trailing: sensor.confirmed
|
||||||
|
? const Icon(Icons.chevron_right)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.access_time, color: Colors.amber, size: 18),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
onTap: () => showSensorDetailSheet(context, sensor.id),
|
onTap: () => showSensorDetailSheet(context, sensor.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -102,7 +102,10 @@ final onboardingRepositoryProvider = Provider<OnboardingRepository>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final sensorRepositoryProvider = Provider<SensorRepository>((ref) {
|
final sensorRepositoryProvider = Provider<SensorRepository>((ref) {
|
||||||
return PhoenixSensorRepository(client: ref.watch(sensorClientProvider));
|
return PhoenixSensorRepository(
|
||||||
|
client: ref.watch(sensorClientProvider),
|
||||||
|
realtime: _requireRealtime(ref),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final tagRepositoryProvider = Provider<TagRepository>((ref) {
|
final tagRepositoryProvider = Provider<TagRepository>((ref) {
|
||||||
@@ -157,6 +160,12 @@ final floorPlanModeProvider =
|
|||||||
// Live data streams
|
// Live data streams
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Raw events pushed by the server's SensorsChannel.
|
||||||
|
/// Each map has an `event` key: `sensor_announced` or `sensor_enrollment_timeout`.
|
||||||
|
final sensorsChannelProvider = StreamProvider<Map<String, dynamic>>((ref) {
|
||||||
|
return ref.watch(sensorRepositoryProvider).sensorEvents();
|
||||||
|
});
|
||||||
|
|
||||||
final tagPositionsProvider = StreamProvider<List<TagPosition>>((ref) {
|
final tagPositionsProvider = StreamProvider<List<TagPosition>>((ref) {
|
||||||
final repo = ref.watch(tagRepositoryProvider);
|
final repo = ref.watch(tagRepositoryProvider);
|
||||||
return repo.watchPositions();
|
return repo.watchPositions();
|
||||||
|
|||||||
Reference in New Issue
Block a user