feat: save and offer Wi-Fi credentials when adding new sensors

This commit is contained in:
2026-05-17 19:59:24 +02:00
parent bcc8b5ff39
commit 2351976adc
2 changed files with 257 additions and 35 deletions
@@ -1,8 +1,11 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../domain/models/server_config.dart'; import '../../../domain/models/server_config.dart';
typedef Credentials = ({String username, String password}); typedef Credentials = ({String username, String password});
typedef WifiCredential = ({String ssid, String password});
class CredentialStore { class CredentialStore {
static const _usernameKey = 'localiserd_username'; static const _usernameKey = 'localiserd_username';
@@ -11,6 +14,7 @@ class CredentialStore {
static const _portKey = 'localiserd_port'; static const _portKey = 'localiserd_port';
static const _mqttHostKey = 'mqtt_host'; static const _mqttHostKey = 'mqtt_host';
static const _mqttPortKey = 'mqtt_port'; static const _mqttPortKey = 'mqtt_port';
static const _wifiCredentialsKey = 'wifi_credentials';
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
@@ -66,6 +70,36 @@ class CredentialStore {
return (host: host, port: port); return (host: host, port: port);
} }
Future<List<WifiCredential>> loadWifiCredentials() async {
final raw = await _storage.read(key: _wifiCredentialsKey);
if (raw == null) return [];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list
.whereType<Map<String, dynamic>>()
.map((m) => (
ssid: m['ssid'] as String? ?? '',
password: m['password'] as String? ?? '',
))
.where((c) => c.ssid.isNotEmpty)
.toList();
} catch (_) {
return [];
}
}
Future<void> 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<void> clear() => Future.wait([ Future<void> clear() => Future.wait([
_storage.delete(key: _usernameKey), _storage.delete(key: _usernameKey),
_storage.delete(key: _passwordKey), _storage.delete(key: _passwordKey),
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../data/sources/ble/ble_provisioner.dart'; import '../../data/sources/ble/ble_provisioner.dart';
import '../../data/sources/local/credential_store.dart';
import '../../providers.dart'; import '../../providers.dart';
enum _Step { scan, configure, done } enum _Step { scan, configure, done }
@@ -20,6 +21,8 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
final _provisioner = BleProvisioner(); final _provisioner = BleProvisioner();
final _ssidController = TextEditingController(); final _ssidController = TextEditingController();
final _wifiPasswordController = TextEditingController(); final _wifiPasswordController = TextEditingController();
final _mqttHostController = TextEditingController();
final _mqttPortController = TextEditingController();
late final PageController _pageController; late final PageController _pageController;
late Stream<BleScanResult> _scanStream; late Stream<BleScanResult> _scanStream;
@@ -28,13 +31,37 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
_Step _step = _Step.scan; _Step _step = _Step.scan;
BleScanResult? _selected; BleScanResult? _selected;
bool _provisioning = false; bool _provisioning = false;
bool _mqttOverrideEnabled = false;
bool _usingNewWifiConnection = true;
bool _saveNewCredential = true;
String? _error; String? _error;
List<WifiCredential> _savedWifiCredentials = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pageController = PageController(); _pageController = PageController();
_scanStream = _provisioner.scan(); _scanStream = _provisioner.scan();
_loadStoredCredentials();
}
Future<void> _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 @override
@@ -42,6 +69,8 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
_provisioner.dispose(); _provisioner.dispose();
_ssidController.dispose(); _ssidController.dispose();
_wifiPasswordController.dispose(); _wifiPasswordController.dispose();
_mqttHostController.dispose();
_mqttPortController.dispose();
_pageController.dispose(); _pageController.dispose();
super.dispose(); super.dispose();
} }
@@ -53,17 +82,38 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
_error = null; _error = null;
}); });
try { 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( await _provisioner.provision(
_selected!.deviceId, _selected!.deviceId,
ssid: _ssidController.text.trim(), ssid: ssid,
wifiPassword: _wifiPasswordController.text, wifiPassword: wifiPassword,
mqttHost: mqttBroker?.host, mqttHost: mqttHost,
mqttPort: mqttBroker?.port, mqttPort: mqttPort,
); );
await ref await ref
.read(sensorRepositoryProvider) .read(sensorRepositoryProvider)
.createSensor(_selected!.name, name: _selected!.name); .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) { if (mounted) {
setState(() => _step = _Step.done); setState(() => _step = _Step.done);
_pageController.animateToPage( _pageController.animateToPage(
@@ -112,6 +162,8 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
_error = null; _error = null;
_ssidController.clear(); _ssidController.clear();
_wifiPasswordController.clear(); _wifiPasswordController.clear();
_usingNewWifiConnection = _savedWifiCredentials.isEmpty;
_saveNewCredential = true;
_scanGeneration++; _scanGeneration++;
}); });
_pageController.animateToPage( _pageController.animateToPage(
@@ -119,6 +171,7 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
_loadStoredCredentials();
} }
@override @override
@@ -183,6 +236,18 @@ class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
_ConfigurePage( _ConfigurePage(
ssidController: _ssidController, ssidController: _ssidController,
wifiPasswordController: _wifiPasswordController, 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, provisioning: _provisioning,
error: _error, error: _error,
onProvision: _provision, onProvision: _provision,
@@ -309,6 +374,15 @@ class _ConfigurePage extends StatefulWidget {
const _ConfigurePage({ const _ConfigurePage({
required this.ssidController, required this.ssidController,
required this.wifiPasswordController, 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.provisioning,
required this.error, required this.error,
required this.onProvision, required this.onProvision,
@@ -316,6 +390,15 @@ class _ConfigurePage extends StatefulWidget {
final TextEditingController ssidController; final TextEditingController ssidController;
final TextEditingController wifiPasswordController; final TextEditingController wifiPasswordController;
final TextEditingController mqttHostController;
final TextEditingController mqttPortController;
final List<WifiCredential> savedWifiCredentials;
final bool mqttOverrideEnabled;
final ValueChanged<bool> onMqttOverrideChanged;
final bool usingNewWifiConnection;
final ValueChanged<bool> onWifiConnectionChanged;
final bool saveNewCredential;
final ValueChanged<bool> onSaveNewCredentialChanged;
final bool provisioning; final bool provisioning;
final String? error; final String? error;
final VoidCallback onProvision; final VoidCallback onProvision;
@@ -324,51 +407,156 @@ class _ConfigurePage extends StatefulWidget {
State<_ConfigurePage> createState() => _ConfigurePageState(); State<_ConfigurePage> createState() => _ConfigurePageState();
} }
typedef WifiCredentialListItem = ({
String? ssid,
String? password,
bool realValue,
});
class _ConfigurePageState extends State<_ConfigurePage> { class _ConfigurePageState extends State<_ConfigurePage> {
bool _obscurePassword = true; bool _obscurePassword = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom; 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 fieldScrollPadding = EdgeInsets.only(bottom: bottomInset + 24);
final anyCredentialsSaved = widget.savedWifiCredentials.isNotEmpty;
final wifiDropdownEntries = [
...widget.savedWifiCredentials.map((creds) {
return DropdownMenuEntry<WifiCredentialListItem>(
value: (ssid: creds.ssid, password: creds.password, realValue: true),
label: creds.ssid,
leadingIcon: const Icon(Icons.wifi),
);
}),
DropdownMenuEntry<WifiCredentialListItem>(
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( return SingleChildScrollView(
padding: EdgeInsets.only(top: 16, bottom: bottomInset + 24), padding: EdgeInsets.only(top: 24, bottom: bottomInset + 24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TextField( if (anyCredentialsSaved) ...[
controller: widget.ssidController, DropdownMenu<WifiCredentialListItem>(
scrollPadding: fieldScrollPadding, dropdownMenuEntries: wifiDropdownEntries,
decoration: const InputDecoration( initialSelection: firstEntry,
labelText: 'WiFi SSID', onSelected: (value) {
border: OutlineInputBorder(), 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),
), ],
const SizedBox(height: 12), if (widget.usingNewWifiConnection) ...[
TextField( TextField(
controller: widget.wifiPasswordController, controller: widget.ssidController,
scrollPadding: fieldScrollPadding, scrollPadding: fieldScrollPadding,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: 'WiFi Password', labelText: 'Wi-Fi network name (SSID)',
border: const OutlineInputBorder(), border: OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
), ),
textInputAction: TextInputAction.next,
), ),
obscureText: _obscurePassword, const SizedBox(height: 12),
textInputAction: TextInputAction.done, TextField(
onSubmitted: (_) { controller: widget.wifiPasswordController,
if (widget.ssidController.text.trim().isNotEmpty) { scrollPadding: fieldScrollPadding,
widget.onProvision(); 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), const SizedBox(height: 16),
if (widget.error != null) if (widget.error != null)