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 });
|
||||
existing.draggable(mode === 'sensorMove');
|
||||
existing.findOne('Text')?.text(label);
|
||||
existing.findOne('.sensor-dot')?.fill(sensor.confirmed ? '#1565C0' : '#9E9E9E');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -394,8 +395,9 @@ function _buildSensorGroup(sensor, absX, absY, label) {
|
||||
|
||||
// Visible dot — hitFunc expands the touch target without changing appearance.
|
||||
group.add(new Konva.Circle({
|
||||
name: 'sensor-dot',
|
||||
radius: 8,
|
||||
fill: '#1565C0',
|
||||
fill: sensor.confirmed ? '#1565C0' : '#9E9E9E',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 2,
|
||||
hitFunc(ctx, shape) {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import '../../domain/models/sensor.dart';
|
||||
import '../sources/localiser/realtime_data_client.dart';
|
||||
import '../sources/localiser/sensor_client.dart';
|
||||
import 'sensor_repository.dart';
|
||||
|
||||
class PhoenixSensorRepository implements SensorRepository {
|
||||
const PhoenixSensorRepository({required this.client});
|
||||
const PhoenixSensorRepository({
|
||||
required this.client,
|
||||
required this.realtime,
|
||||
});
|
||||
|
||||
final SensorClient client;
|
||||
final RealtimeDataClient realtime;
|
||||
|
||||
@override
|
||||
Future<Sensor> createSensor(String sensorId, {String? name}) async =>
|
||||
Sensor.fromJson(await client.createSensor(sensorId, name: name));
|
||||
|
||||
@override
|
||||
Future<List<Sensor>> getSensors() async {
|
||||
@@ -35,11 +44,16 @@ class PhoenixSensorRepository implements SensorRepository {
|
||||
|
||||
@override
|
||||
Future<Sensor> placeSensor(int id,
|
||||
{required int roomId, required double x, required double y}) async =>
|
||||
{required int roomId, required double x, required double y}) async =>
|
||||
Sensor.fromJson(
|
||||
await client.placeSensor(id, {'room_id': roomId, 'x': x, 'y': y}));
|
||||
|
||||
@override
|
||||
Future<Sensor> unplaceSensor(int id) async =>
|
||||
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';
|
||||
|
||||
abstract class SensorRepository {
|
||||
Future<Sensor> createSensor(String sensorId, {String? name});
|
||||
Future<List<Sensor>> getSensors();
|
||||
Future<List<Sensor>> getUnplacedSensors();
|
||||
Future<Sensor> getSensor(int id);
|
||||
@@ -9,4 +10,8 @@ abstract class SensorRepository {
|
||||
Future<Sensor> placeSensor(int id,
|
||||
{required int roomId, required double x, required double y});
|
||||
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;
|
||||
|
||||
Stream<BleScanResult> scan() async* {
|
||||
await _requestScanPermissions();
|
||||
// Continuously scans for nearby ESP32 sensors, restarting after each
|
||||
// 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;
|
||||
|
||||
await FlutterBluePlus.startScan(
|
||||
withServices: [Guid(_serviceUuid)],
|
||||
timeout: const Duration(seconds: 30),
|
||||
Future<void> startScan() async {
|
||||
if (controller.isClosed) return;
|
||||
started = true;
|
||||
try {
|
||||
await FlutterBluePlus.startScan(
|
||||
withServices: [Guid(_serviceUuid)],
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
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) {
|
||||
final name = r.device.platformName;
|
||||
if (name.startsWith('anchor_') && !controller.isClosed) {
|
||||
controller.add(BleScanResult(
|
||||
deviceId: r.device.remoteId.str,
|
||||
name: name,
|
||||
rssi: r.rssi,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if(!FlutterBluePlus.isScanningNow) {
|
||||
await startScan();
|
||||
}
|
||||
},
|
||||
|
||||
onCancel: () {
|
||||
resultsSub?.cancel();
|
||||
stateSub?.cancel();
|
||||
FlutterBluePlus.stopScan();
|
||||
controller.close();
|
||||
},
|
||||
);
|
||||
|
||||
await for (final results in FlutterBluePlus.scanResults) {
|
||||
for (final r in results) {
|
||||
final name = r.device.platformName;
|
||||
if (name.startsWith('anchor_')) {
|
||||
yield BleScanResult(
|
||||
deviceId: r.device.remoteId.str,
|
||||
name: name,
|
||||
rssi: r.rssi,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<void> stopScan() => FlutterBluePlus.stopScan();
|
||||
|
||||
@@ -56,6 +56,28 @@ class RealtimeDataClient {
|
||||
.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.
|
||||
/// The channel must have been joined first via [channel].
|
||||
Future<Map<String, dynamic>> push(
|
||||
|
||||
@@ -25,6 +25,13 @@ class SensorClient extends LocaliserdClient {
|
||||
Future<Map<String, dynamic>> unplaceSensor(int id) async =>
|
||||
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(
|
||||
int id, double referenceDistance) async =>
|
||||
await post('/api/sensors/$id/calibration/start',
|
||||
|
||||
@@ -2,6 +2,7 @@ class Sensor {
|
||||
const Sensor({
|
||||
required this.id,
|
||||
required this.sensorId,
|
||||
required this.confirmed,
|
||||
this.name,
|
||||
this.roomId,
|
||||
this.x,
|
||||
@@ -10,6 +11,7 @@ class Sensor {
|
||||
|
||||
final int id;
|
||||
final String sensorId; // BLE MAC / provisioning device ID
|
||||
final bool confirmed; // true once seen on MQTT
|
||||
final String? name; // human-readable label
|
||||
final int? roomId;
|
||||
final double? x; // room relative
|
||||
@@ -21,6 +23,7 @@ class Sensor {
|
||||
factory Sensor.fromJson(Map<String, dynamic> json) => Sensor(
|
||||
id: json['id'] as int,
|
||||
sensorId: json['sensor_id'] as String,
|
||||
confirmed: json['confirmed'] as bool,
|
||||
name: json['name'] as String?,
|
||||
roomId: json['room_id'] as int?,
|
||||
x: (json['x'] as num?)?.toDouble(),
|
||||
@@ -37,6 +40,7 @@ class Sensor {
|
||||
Sensor(
|
||||
id: id,
|
||||
sensorId: sensorId,
|
||||
confirmed: confirmed,
|
||||
name: name ?? this.name,
|
||||
roomId: roomId ?? this.roomId,
|
||||
x: x ?? this.x,
|
||||
|
||||
@@ -5,10 +5,10 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../data/sources/ble/ble_provisioner.dart';
|
||||
import '../../providers.dart';
|
||||
|
||||
enum _Step { provision, done }
|
||||
enum _Step { scan, configure, done }
|
||||
|
||||
// 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 {
|
||||
const BleProvisionSheet({super.key});
|
||||
|
||||
@@ -20,10 +20,12 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
||||
final _provisioner = BleProvisioner();
|
||||
final _ssidController = TextEditingController();
|
||||
final _wifiPasswordController = TextEditingController();
|
||||
late final PageController _pageController;
|
||||
|
||||
late Stream<BleScanResult> _scanStream;
|
||||
int _scanGeneration = 0;
|
||||
|
||||
_Step _step = _Step.provision;
|
||||
_Step _step = _Step.scan;
|
||||
BleScanResult? _selected;
|
||||
bool _provisioning = false;
|
||||
String? _error;
|
||||
@@ -31,6 +33,7 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController();
|
||||
_scanStream = _provisioner.scan();
|
||||
}
|
||||
|
||||
@@ -39,6 +42,7 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
||||
_provisioner.dispose();
|
||||
_ssidController.dispose();
|
||||
_wifiPasswordController.dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -57,7 +61,17 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
||||
mqttHost: mqttBroker?.host,
|
||||
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) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
} 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() {
|
||||
setState(() {
|
||||
_step = _Step.provision;
|
||||
_step = _Step.scan;
|
||||
_selected = null;
|
||||
_error = null;
|
||||
_ssidController.clear();
|
||||
_wifiPasswordController.clear();
|
||||
_scanStream = _provisioner.scan();
|
||||
_scanGeneration++;
|
||||
});
|
||||
_pageController.animateToPage(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -83,44 +128,185 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
||||
initialChildSize: 0.6,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24,
|
||||
16,
|
||||
24,
|
||||
MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: _step == _Step.done
|
||||
? _DoneStep(
|
||||
onGoToFloorPlan: () {
|
||||
final router = GoRouter.of(context);
|
||||
Navigator.of(context).pop();
|
||||
router.go('/floorplan');
|
||||
},
|
||||
onAddAnother: _reset,
|
||||
onDone: () => Navigator.of(context).pop(),
|
||||
)
|
||||
: _ProvisionStep(
|
||||
scrollController: scrollController,
|
||||
scanStream: _scanStream,
|
||||
selected: _selected,
|
||||
onSelected: (r) => setState(() => _selected = r),
|
||||
ssidController: _ssidController,
|
||||
wifiPasswordController: _wifiPasswordController,
|
||||
provisioning: _provisioning,
|
||||
error: _error,
|
||||
onProvision: _provision,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_step != _Step.done) ...[
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
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: () {
|
||||
final router = GoRouter.of(context);
|
||||
Navigator.of(context).pop();
|
||||
router.go('/floorplan');
|
||||
},
|
||||
onAddAnother: _reset,
|
||||
onDone: () => Navigator.of(context).pop(),
|
||||
),
|
||||
]
|
||||
.map(
|
||||
(page) => Padding(
|
||||
padding: const .symmetric(horizontal: 24),
|
||||
child: page,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProvisionStep extends StatefulWidget {
|
||||
const _ProvisionStep({
|
||||
class _ScanPage extends StatefulWidget {
|
||||
const _ScanPage({
|
||||
required this.scrollController,
|
||||
required this.scanStream,
|
||||
required this.generation,
|
||||
required this.selected,
|
||||
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.wifiPasswordController,
|
||||
required this.provisioning,
|
||||
@@ -128,10 +314,6 @@ class _ProvisionStep extends StatefulWidget {
|
||||
required this.onProvision,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final Stream<BleScanResult> scanStream;
|
||||
final BleScanResult? selected;
|
||||
final ValueChanged<BleScanResult> onSelected;
|
||||
final TextEditingController ssidController;
|
||||
final TextEditingController wifiPasswordController;
|
||||
final bool provisioning;
|
||||
@@ -139,107 +321,83 @@ class _ProvisionStep extends StatefulWidget {
|
||||
final VoidCallback onProvision;
|
||||
|
||||
@override
|
||||
State<_ProvisionStep> createState() => _ProvisionStepState();
|
||||
State<_ConfigurePage> createState() => _ConfigurePageState();
|
||||
}
|
||||
|
||||
class _ProvisionStepState extends State<_ProvisionStep> {
|
||||
final _discovered = <String, BleScanResult>{};
|
||||
class _ConfigurePageState extends State<_ConfigurePage> {
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
controller: widget.scrollController,
|
||||
children: [
|
||||
Text('Add sensor', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
// scrollPadding ensures the field scrolls fully clear of the keyboard.
|
||||
final fieldScrollPadding = EdgeInsets.only(bottom: bottomInset + 24);
|
||||
|
||||
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),
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(top: 16, bottom: bottomInset + 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: widget.ssidController,
|
||||
scrollPadding: fieldScrollPadding,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'WiFi SSID',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: .next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: widget.wifiPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'WiFi password',
|
||||
border: OutlineInputBorder(),
|
||||
scrollPadding: fieldScrollPadding,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'WiFi Password',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
obscureText: true,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) {
|
||||
if (widget.ssidController.text.trim().isNotEmpty) {
|
||||
widget.onProvision();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
if (widget.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(widget.error!,
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
if (widget.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
widget.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: widget.ssidController,
|
||||
builder: (context, _) {
|
||||
final canProvision = widget.ssidController.text.trim().isNotEmpty;
|
||||
return FilledButton(
|
||||
onPressed: (widget.provisioning || !canProvision)
|
||||
? null
|
||||
: widget.onProvision,
|
||||
child: widget.provisioning
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Provision & add'),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
FilledButton(
|
||||
onPressed:
|
||||
(widget.selected == null || widget.provisioning) ? null : widget.onProvision,
|
||||
child: widget.provisioning
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Provision & add'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -257,42 +415,42 @@ class _DoneStep extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, size: 56),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sensor provisioned!',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The sensor will appear in the list once it connects to the network. '
|
||||
'Open the floor plan to place it.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: const Text('Place on floor plan'),
|
||||
onPressed: onGoToFloorPlan,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add another sensor'),
|
||||
onPressed: onAddAnother,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: onDone,
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, size: 56),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sensor provisioned!',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The sensor will appear in the list once it connects to the network. '
|
||||
'Open the floor plan to place it.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: const Text('Place on floor plan'),
|
||||
onPressed: onGoToFloorPlan,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add another sensor'),
|
||||
onPressed: onAddAnother,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(onPressed: onDone, child: const Text('Done')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,21 @@ class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
||||
ref.listen(sensorsProvider, (_, next) {
|
||||
next.whenData((s) => _editorKey.currentState?.loadSensors(s));
|
||||
});
|
||||
ref.listen(sensorsChannelProvider, (_, next) {
|
||||
next.whenData((msg) {
|
||||
switch (msg['event'] as String?) {
|
||||
case 'sensor_announced':
|
||||
ref.invalidate(sensorsProvider);
|
||||
case 'sensor_enrollment_timeout':
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Sensor did not come online in time. '
|
||||
'Check WiFi credentials and try again.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
ref.listen(tagPositionsProvider, (_, next) {
|
||||
next.whenData((t) => _editorKey.currentState?.updateTags(t));
|
||||
});
|
||||
|
||||
@@ -149,6 +149,7 @@ class FloorPlanEditorState extends State<FloorPlanEditor> {
|
||||
'floor_x': s.x,
|
||||
'floor_y': s.y,
|
||||
'room_id': s.roomId,
|
||||
'confirmed': s.confirmed,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
|
||||
@@ -13,6 +13,22 @@ class SensorListScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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(
|
||||
appBar: AppBar(title: const Text('Sensors')),
|
||||
body: Column(
|
||||
@@ -35,16 +51,12 @@ class SensorListScreen extends ConsumerWidget {
|
||||
if (unplaced.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
label: 'Unplaced', count: unplaced.length),
|
||||
...unplaced.map((s) => _SensorTile(
|
||||
sensor: s,
|
||||
)),
|
||||
...unplaced.map((s) => _SensorTile(sensor: s)),
|
||||
],
|
||||
if (placed.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
label: 'Placed', count: placed.length),
|
||||
...placed.map((s) => _SensorTile(
|
||||
sensor: s,
|
||||
)),
|
||||
...placed.map((s) => _SensorTile(sensor: s)),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -87,22 +99,30 @@ class _SectionHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _SensorTile extends StatelessWidget {
|
||||
const _SensorTile({required this.sensor, this.isSelected = false});
|
||||
const _SensorTile({required this.sensor});
|
||||
|
||||
final Sensor sensor;
|
||||
final bool isSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
selected: isSelected,
|
||||
leading: Icon(sensor.isPlaced
|
||||
? Icons.sensors
|
||||
: Icons.sensors_off_outlined),
|
||||
title: Text(sensor.displayName),
|
||||
subtitle: Text(
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
+10
-1
@@ -102,7 +102,10 @@ final onboardingRepositoryProvider = Provider<OnboardingRepository>((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) {
|
||||
@@ -157,6 +160,12 @@ final floorPlanModeProvider =
|
||||
// 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 repo = ref.watch(tagRepositoryProvider);
|
||||
return repo.watchPositions();
|
||||
|
||||
Reference in New Issue
Block a user