import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../data/sources/ble/ble_provisioner.dart'; import '../../providers.dart'; enum _Step { provision, done } // Shared bottom sheet used by onboarding and the main sensor screens. // Flow: scan → select device → enter WiFi credentials → provision → place on map. class BleProvisionSheet extends ConsumerStatefulWidget { const BleProvisionSheet({super.key}); @override ConsumerState createState() => _BleProvisionSheetState(); } class _BleProvisionSheetState extends ConsumerState { final _provisioner = BleProvisioner(); final _ssidController = TextEditingController(); final _wifiPasswordController = TextEditingController(); late final Stream _scanStream; _Step _step = _Step.provision; BleScanResult? _selected; bool _provisioning = false; String? _error; @override void initState() { super.initState(); _scanStream = _provisioner.scan(); } @override void dispose() { _provisioner.dispose(); _ssidController.dispose(); _wifiPasswordController.dispose(); super.dispose(); } Future _provision() async { if (_selected == null) return; setState(() { _provisioning = true; _error = null; }); try { final mqttBroker = await ref.read(mqttBrokerProvider.future); await _provisioner.provision( _selected!.deviceId, ssid: _ssidController.text.trim(), wifiPassword: _wifiPasswordController.text, mqttHost: mqttBroker?.host, mqttPort: mqttBroker?.port, ); if (mounted) setState(() => _step = _Step.done); } catch (e) { if (mounted) setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _provisioning = false); } } void _reset() { setState(() { _step = _Step.provision; _selected = null; _error = null; _ssidController.clear(); _wifiPasswordController.clear(); }); } @override Widget build(BuildContext context) { return DraggableScrollableSheet( expand: false, 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, ), ), ); } } class _ProvisionStep extends StatefulWidget { const _ProvisionStep({ required this.scrollController, required this.scanStream, required this.selected, required this.onSelected, required this.ssidController, required this.wifiPasswordController, required this.provisioning, required this.error, required this.onProvision, }); final ScrollController scrollController; final Stream scanStream; final BleScanResult? selected; final ValueChanged onSelected; final TextEditingController ssidController; final TextEditingController wifiPasswordController; final bool provisioning; final String? error; final VoidCallback onProvision; @override State<_ProvisionStep> createState() => _ProvisionStepState(); } class _ProvisionStepState extends State<_ProvisionStep> { final _discovered = {}; @override Widget build(BuildContext context) { return ListView( controller: widget.scrollController, 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( 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( controller: widget.ssidController, decoration: const InputDecoration( labelText: 'WiFi SSID', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: widget.wifiPasswordController, decoration: const InputDecoration( labelText: 'WiFi password', border: OutlineInputBorder(), ), obscureText: true, ), 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)), ), 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'), ), ], ); } } class _DoneStep extends StatelessWidget { const _DoneStep({ required this.onGoToFloorPlan, required this.onAddAnother, required this.onDone, }); final VoidCallback onGoToFloorPlan; final VoidCallback onAddAnother; final VoidCallback onDone; @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'), ), ], ); } }