import 'package:flutter/material.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 { scan, configure, done } // Shared bottom sheet used by onboarding and the main sensor screens. // Flow: scan -> select device (slides to configure) -> provision -> done. 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 PageController _pageController; late Stream _scanStream; int _scanGeneration = 0; _Step _step = _Step.scan; BleScanResult? _selected; bool _provisioning = false; String? _error; @override void initState() { super.initState(); _pageController = PageController(); _scanStream = _provisioner.scan(); } @override void dispose() { _provisioner.dispose(); _ssidController.dispose(); _wifiPasswordController.dispose(); _pageController.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, ); 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 { if (mounted) setState(() => _provisioning = false); } } 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.scan; _selected = null; _error = null; _ssidController.clear(); _wifiPasswordController.clear(); _scanGeneration++; }); _pageController.animateToPage( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } @override Widget build(BuildContext context) { return DraggableScrollableSheet( expand: false, initialChildSize: 0.6, maxChildSize: 0.9, builder: (context, scrollController) => Padding( 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 _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 scanStream; final int generation; final BleScanResult? selected; final ValueChanged onSelected; @override State<_ScanPage> createState() => _ScanPageState(); } class _ScanPageState extends State<_ScanPage> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; final _discovered = {}; @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( 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, required this.error, required this.onProvision, }); final TextEditingController ssidController; final TextEditingController wifiPasswordController; final bool provisioning; final String? error; final VoidCallback onProvision; @override State<_ConfigurePage> createState() => _ConfigurePageState(); } class _ConfigurePageState extends State<_ConfigurePage> { bool _obscurePassword = true; @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; // 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: [ TextField( controller: widget.ssidController, scrollPadding: fieldScrollPadding, decoration: const InputDecoration( labelText: 'WiFi SSID', border: OutlineInputBorder(), ), textInputAction: .next, ), const SizedBox(height: 12), TextField( controller: widget.wifiPasswordController, 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: _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), ), ), 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'), ); }, ), ], ), ); } } 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 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')), ], ), ); } }