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 '../../data/sources/local/credential_store.dart'; import '../../domain/models/sensor.dart'; import '../../providers.dart'; import '../sensors/calibration_sheet.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(); final _mqttHostController = TextEditingController(); final _mqttPortController = TextEditingController(); late final PageController _pageController; late Stream _scanStream; int _scanGeneration = 0; _Step _step = _Step.scan; BleScanResult? _selected; Sensor? _provisionedSensor; bool _provisioning = false; bool _mqttOverrideEnabled = false; bool _usingNewWifiConnection = true; bool _saveNewCredential = true; String? _error; List _savedWifiCredentials = []; @override void initState() { super.initState(); _pageController = PageController(); _scanStream = _provisioner.scan(); _loadStoredCredentials(); } Future _loadStoredCredentials() async { final store = ref.read(credentialStoreProvider); final wifiCreds = await store.loadWifiCredentials(); final mqtt = await store.loadMqttBroker(); if (!mounted) return; setState(() { _savedWifiCredentials = wifiCreds; _usingNewWifiConnection = wifiCreds.isEmpty; if (wifiCreds.isNotEmpty) { _ssidController.text = wifiCreds.first.ssid; _wifiPasswordController.text = wifiCreds.first.password; } if (mqtt != null) { _mqttHostController.text = mqtt.host; _mqttPortController.text = mqtt.port.toString(); } }); } @override void dispose() { _provisioner.dispose(); _ssidController.dispose(); _wifiPasswordController.dispose(); _mqttHostController.dispose(); _mqttPortController.dispose(); _pageController.dispose(); super.dispose(); } Future _provision() async { if (_selected == null) return; setState(() { _provisioning = true; _error = null; }); try { final ssid = _ssidController.text.trim(); final wifiPassword = _wifiPasswordController.text; final mqttHostRaw = _mqttOverrideEnabled ? _mqttHostController.text.trim() : null; final mqttPortRaw = _mqttOverrideEnabled ? _mqttPortController.text.trim() : null; final mqttHost = (mqttHostRaw?.isNotEmpty ?? false) ? mqttHostRaw : null; final mqttPort = (mqttPortRaw?.isNotEmpty ?? false) ? (int.tryParse(mqttPortRaw!) ?? 1883) : null; await _provisioner.provision( _selected!.deviceId, ssid: ssid, wifiPassword: wifiPassword, mqttHost: mqttHost, mqttPort: mqttPort, ); _provisionedSensor = await ref .read(sensorRepositoryProvider) .createSensor(_selected!.name, name: _selected!.name); final store = ref.read(credentialStoreProvider); if (_usingNewWifiConnection && _saveNewCredential) { await store.saveWifiCredential((ssid: ssid, password: wifiPassword)); } if (_mqttOverrideEnabled && mqttHost != null && mqttPort != null) { await store.saveMqttBroker(mqttHost, mqttPort); } 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(); _usingNewWifiConnection = _savedWifiCredentials.isEmpty; _saveNewCredential = true; _scanGeneration++; }); _pageController.animateToPage( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); _loadStoredCredentials(); } @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, mqttHostController: _mqttHostController, mqttPortController: _mqttPortController, savedWifiCredentials: _savedWifiCredentials, mqttOverrideEnabled: _mqttOverrideEnabled, onMqttOverrideChanged: (v) => setState(() => _mqttOverrideEnabled = v), usingNewWifiConnection: _usingNewWifiConnection, onWifiConnectionChanged: (v) => setState(() => _usingNewWifiConnection = v), saveNewCredential: _saveNewCredential, onSaveNewCredentialChanged: (v) => setState(() => _saveNewCredential = v), provisioning: _provisioning, error: _error, onProvision: _provision, ), _DoneStep( onGoToFloorPlan: () { if (_provisionedSensor != null) { _placeOnFloorPlan(context, _provisionedSensor!); } }, onAddAnother: _reset, onDone: () => Navigator.of(context).pop(), onCalibrate: _provisionedSensor == null ? null : () { final sensor = _provisionedSensor!; Navigator.of( context, rootNavigator: true, ).pop(); showCalibrationSheet( context, sensor.id, sensor.sensorId, ); }, ), ] .map( (page) => Padding( padding: const .symmetric(horizontal: 24), child: page, ), ) .toList(), ), ), ], ), ), ); } void _placeOnFloorPlan(BuildContext context, Sensor sensor) { final router = GoRouter.of(context); final fromSensors = router.routeInformationProvider.value.uri.path == '/sensors'; ref.read(sensorPlacementProvider.notifier).state = sensor; ref.read(sensorPlacementOriginSensorsProvider.notifier).state = fromSensors; Navigator.of(context, rootNavigator: true).pop(); router.go('/floorplan'); } } 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.mqttHostController, required this.mqttPortController, required this.savedWifiCredentials, required this.mqttOverrideEnabled, required this.onMqttOverrideChanged, required this.usingNewWifiConnection, required this.onWifiConnectionChanged, required this.saveNewCredential, required this.onSaveNewCredentialChanged, required this.provisioning, required this.error, required this.onProvision, }); final TextEditingController ssidController; final TextEditingController wifiPasswordController; final TextEditingController mqttHostController; final TextEditingController mqttPortController; final List savedWifiCredentials; final bool mqttOverrideEnabled; final ValueChanged onMqttOverrideChanged; final bool usingNewWifiConnection; final ValueChanged onWifiConnectionChanged; final bool saveNewCredential; final ValueChanged onSaveNewCredentialChanged; final bool provisioning; final String? error; final VoidCallback onProvision; @override State<_ConfigurePage> createState() => _ConfigurePageState(); } typedef WifiCredentialListItem = ({ String? ssid, String? password, bool realValue, }); class _ConfigurePageState extends State<_ConfigurePage> { bool _obscurePassword = true; @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; final fieldScrollPadding = EdgeInsets.only(bottom: bottomInset + 24); final anyCredentialsSaved = widget.savedWifiCredentials.isNotEmpty; final wifiDropdownEntries = [ ...widget.savedWifiCredentials.map((creds) { return DropdownMenuEntry( value: (ssid: creds.ssid, password: creds.password, realValue: true), label: creds.ssid, leadingIcon: const Icon(Icons.wifi), ); }), DropdownMenuEntry( value: (realValue: false, ssid: null, password: null), label: 'Use a new Wi-Fi connection', leadingIcon: const Icon(Icons.add), ), ]; final firstCredential = widget.savedWifiCredentials.elementAtOrNull(0); final WifiCredentialListItem firstEntry = ( ssid: firstCredential?.ssid, password: firstCredential?.password, realValue: true, ); return SingleChildScrollView( padding: EdgeInsets.only(top: 24, bottom: bottomInset + 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (anyCredentialsSaved) ...[ DropdownMenu( dropdownMenuEntries: wifiDropdownEntries, initialSelection: firstEntry, onSelected: (value) { if (value == null) return; if (value.realValue) { widget.ssidController.text = value.ssid!; widget.wifiPasswordController.text = value.password!; widget.onWifiConnectionChanged(false); } else { widget.ssidController.clear(); widget.wifiPasswordController.clear(); widget.onWifiConnectionChanged(true); } }, label: const Text('Saved Wi-Fi credentials'), leadingIcon: widget.usingNewWifiConnection ? const Icon(Icons.add) : const Icon(Icons.wifi), ), const SizedBox(height: 12), ], if (widget.usingNewWifiConnection) ...[ TextField( controller: widget.ssidController, scrollPadding: fieldScrollPadding, decoration: const InputDecoration( labelText: 'Wi-Fi network name (SSID)', border: OutlineInputBorder(), ), textInputAction: TextInputAction.next, ), const SizedBox(height: 12), TextField( controller: widget.wifiPasswordController, scrollPadding: fieldScrollPadding, decoration: InputDecoration( labelText: 'Wi-Fi 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(); } }, ), CheckboxListTile( contentPadding: EdgeInsets.zero, title: const Text('Save this Wi-Fi connection'), value: widget.saveNewCredential, onChanged: (v) => widget.onSaveNewCredentialChanged(v ?? true), ), ], // const SizedBox(height: 16), ExpansionTile( title: const Text('Advanced'), tilePadding: EdgeInsets.zero, childrenPadding: EdgeInsets.zero, expandedCrossAxisAlignment: CrossAxisAlignment.stretch, children: [ CheckboxListTile( contentPadding: EdgeInsets.zero, title: const Text('Use MQTT broker host override'), value: widget.mqttOverrideEnabled, onChanged: (v) => widget.onMqttOverrideChanged(v ?? false), ), TextField( controller: widget.mqttHostController, enabled: widget.mqttOverrideEnabled, scrollPadding: fieldScrollPadding, decoration: const InputDecoration( labelText: 'MQTT Host', border: OutlineInputBorder(), ), keyboardType: TextInputType.url, textInputAction: TextInputAction.next, ), const SizedBox(height: 12), TextField( controller: widget.mqttPortController, enabled: widget.mqttOverrideEnabled, scrollPadding: fieldScrollPadding, decoration: const InputDecoration( labelText: 'MQTT Port', border: OutlineInputBorder(), ), keyboardType: TextInputType.number, textInputAction: TextInputAction.done, onSubmitted: (_) { if (widget.ssidController.text.trim().isNotEmpty) { widget.onProvision(); } }, ), const SizedBox(height: 8), ], ), 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, this.onCalibrate, }); final VoidCallback onGoToFloorPlan; final VoidCallback onAddAnother; final VoidCallback onDone; final VoidCallback? onCalibrate; @override Widget build(BuildContext context) { final theme = Theme.of(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.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.textTheme.bodyMedium, textAlign: TextAlign.center, ), const SizedBox(height: 32), FilledButton.icon( icon: const Icon(Icons.gps_fixed), label: const Text('Place on floor plan'), onPressed: onGoToFloorPlan, ), const SizedBox(height: 16), OutlinedButton.icon( icon: const Icon(Icons.tune), label: const Text('Calibrate sensor'), onPressed: onCalibrate, ), const SizedBox(height: 8), Text( 'For best accuracy, calibrate after mounting the sensor in its intended position.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), 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')), ], ), ); } }