diff --git a/lib/data/sources/local/credential_store.dart b/lib/data/sources/local/credential_store.dart index a6ba176..7fc936e 100644 --- a/lib/data/sources/local/credential_store.dart +++ b/lib/data/sources/local/credential_store.dart @@ -1,8 +1,11 @@ +import 'dart:convert'; + import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../../../domain/models/server_config.dart'; typedef Credentials = ({String username, String password}); +typedef WifiCredential = ({String ssid, String password}); class CredentialStore { static const _usernameKey = 'localiserd_username'; @@ -11,6 +14,7 @@ class CredentialStore { static const _portKey = 'localiserd_port'; static const _mqttHostKey = 'mqtt_host'; static const _mqttPortKey = 'mqtt_port'; + static const _wifiCredentialsKey = 'wifi_credentials'; final _storage = const FlutterSecureStorage(); @@ -66,6 +70,36 @@ class CredentialStore { return (host: host, port: port); } + Future> loadWifiCredentials() async { + final raw = await _storage.read(key: _wifiCredentialsKey); + if (raw == null) return []; + try { + final list = jsonDecode(raw) as List; + return list + .whereType>() + .map((m) => ( + ssid: m['ssid'] as String? ?? '', + password: m['password'] as String? ?? '', + )) + .where((c) => c.ssid.isNotEmpty) + .toList(); + } catch (_) { + return []; + } + } + + Future saveWifiCredential(WifiCredential credential) async { + final existing = await loadWifiCredentials(); + final updated = [ + credential, + ...existing.where((c) => c.ssid != credential.ssid), + ]; + final json = jsonEncode( + updated.map((c) => {'ssid': c.ssid, 'password': c.password}).toList(), + ); + await _storage.write(key: _wifiCredentialsKey, value: json); + } + Future clear() => Future.wait([ _storage.delete(key: _usernameKey), _storage.delete(key: _passwordKey), diff --git a/lib/features/ble_provision/ble_provision_sheet.dart b/lib/features/ble_provision/ble_provision_sheet.dart index 66b73de..76c775a 100644 --- a/lib/features/ble_provision/ble_provision_sheet.dart +++ b/lib/features/ble_provision/ble_provision_sheet.dart @@ -3,6 +3,7 @@ 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 '../../providers.dart'; enum _Step { scan, configure, done } @@ -20,6 +21,8 @@ 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; @@ -28,13 +31,37 @@ class _BleProvisionSheetState extends ConsumerState { _Step _step = _Step.scan; BleScanResult? _selected; 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 @@ -42,6 +69,8 @@ class _BleProvisionSheetState extends ConsumerState { _provisioner.dispose(); _ssidController.dispose(); _wifiPasswordController.dispose(); + _mqttHostController.dispose(); + _mqttPortController.dispose(); _pageController.dispose(); super.dispose(); } @@ -53,17 +82,38 @@ class _BleProvisionSheetState extends ConsumerState { _error = null; }); try { - final mqttBroker = await ref.read(mqttBrokerProvider.future); + 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: _ssidController.text.trim(), - wifiPassword: _wifiPasswordController.text, - mqttHost: mqttBroker?.host, - mqttPort: mqttBroker?.port, + ssid: ssid, + wifiPassword: wifiPassword, + mqttHost: mqttHost, + mqttPort: mqttPort, ); 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( @@ -112,6 +162,8 @@ class _BleProvisionSheetState extends ConsumerState { _error = null; _ssidController.clear(); _wifiPasswordController.clear(); + _usingNewWifiConnection = _savedWifiCredentials.isEmpty; + _saveNewCredential = true; _scanGeneration++; }); _pageController.animateToPage( @@ -119,6 +171,7 @@ class _BleProvisionSheetState extends ConsumerState { duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); + _loadStoredCredentials(); } @override @@ -183,6 +236,18 @@ class _BleProvisionSheetState extends ConsumerState { _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, @@ -309,6 +374,15 @@ 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, @@ -316,6 +390,15 @@ class _ConfigurePage extends StatefulWidget { 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; @@ -324,51 +407,156 @@ class _ConfigurePage extends StatefulWidget { 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; - // scrollPadding ensures the field scrolls fully clear of the keyboard. 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: 16, bottom: bottomInset + 24), + padding: EdgeInsets.only(top: 24, bottom: bottomInset + 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextField( - controller: widget.ssidController, - scrollPadding: fieldScrollPadding, - decoration: const InputDecoration( - labelText: 'WiFi SSID', - border: OutlineInputBorder(), + 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), ), - 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), + 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, ), - obscureText: _obscurePassword, - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (widget.ssidController.text.trim().isNotEmpty) { - widget.onProvision(); - } - }, + 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)