From c36d63a0ad913da6d50a7385925cb46ea4df5690 Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 12 Mar 2026 20:19:24 +0100 Subject: [PATCH] Add Flutter live traffic monitor --- lib/screens/device_config_screen.dart | 231 +++++++++++++++++++------- 1 file changed, 168 insertions(+), 63 deletions(-) diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index c949030..706796f 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -170,6 +170,12 @@ class _DeviceConfigScreenState extends State { bool _telemetryEnabled = false; bool _repeatEnabled = false; bool _showCustomRadioSettings = false; + bool _isSavingPublicInfo = false; + bool _isSavingRadioSettings = false; + bool _publicInfoSaved = false; + bool _radioSettingsSaved = false; + String? _publicInfoError; + String? _radioSettingsError; String _selectedBandwidth = '62.5 kHz'; int _selectedSpreadingFactor = 8; int _selectedCodingRate = 8; @@ -336,11 +342,35 @@ class _DeviceConfigScreenState extends State { }); } + void _markPublicInfoDirty() { + if (_publicInfoSaved || _publicInfoError != null) { + setState(() { + _publicInfoSaved = false; + _publicInfoError = null; + }); + } + } + + void _markRadioSettingsDirty() { + if (_radioSettingsSaved || _radioSettingsError != null) { + setState(() { + _radioSettingsSaved = false; + _radioSettingsError = null; + }); + } + } + Future _savePublicInfo() async { final connectionProvider = context.read(); final deviceInfo = connectionProvider.deviceInfo; final validator = ValidationService(); + setState(() { + _isSavingPublicInfo = true; + _publicInfoSaved = false; + _publicInfoError = null; + }); + try { // Save name if (_nameController.text.isNotEmpty) { @@ -353,12 +383,10 @@ class _DeviceConfigScreenState extends State { final latResult = validator.parseLatitude(_latController.text); if (!latResult.isSuccess) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(latResult.errorMessage!), - backgroundColor: Colors.red, - ), - ); + setState(() { + _publicInfoError = latResult.errorMessage!; + _isSavingPublicInfo = false; + }); } return; } @@ -366,12 +394,10 @@ class _DeviceConfigScreenState extends State { final lonResult = validator.parseLongitude(_lonController.text); if (!lonResult.isSuccess) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(lonResult.errorMessage!), - backgroundColor: Colors.red, - ), - ); + setState(() { + _publicInfoError = lonResult.errorMessage!; + _isSavingPublicInfo = false; + }); } return; } @@ -405,23 +431,19 @@ class _DeviceConfigScreenState extends State { await connectionProvider.refreshDeviceInfo(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.save), - backgroundColor: Colors.green, - ), - ); + setState(() { + _isSavingPublicInfo = false; + _publicInfoSaved = true; + }); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.failedToSave(e.toString()), - ), - backgroundColor: Colors.red, - ), - ); + setState(() { + _isSavingPublicInfo = false; + _publicInfoError = AppLocalizations.of( + context, + )!.failedToSave(e.toString()); + }); } } } @@ -431,17 +453,21 @@ class _DeviceConfigScreenState extends State { final validator = ValidationService(); final deviceInfo = connectionProvider.deviceInfo; + setState(() { + _isSavingRadioSettings = true; + _radioSettingsSaved = false; + _radioSettingsError = null; + }); + try { // Parse and validate frequency final freqResult = validator.parseFrequency(_freqController.text); if (!freqResult.isSuccess) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(freqResult.errorMessage!), - backgroundColor: Colors.red, - ), - ); + setState(() { + _radioSettingsError = freqResult.errorMessage!; + _isSavingRadioSettings = false; + }); } return; } @@ -453,12 +479,10 @@ class _DeviceConfigScreenState extends State { ); if (!txPowerResult.isSuccess) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(txPowerResult.errorMessage!), - backgroundColor: Colors.red, - ), - ); + setState(() { + _radioSettingsError = txPowerResult.errorMessage!; + _isSavingRadioSettings = false; + }); } return; } @@ -481,23 +505,19 @@ class _DeviceConfigScreenState extends State { await connectionProvider.refreshDeviceInfo(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.save), - backgroundColor: Colors.green, - ), - ); + setState(() { + _isSavingRadioSettings = false; + _radioSettingsSaved = true; + }); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.failedToSave(e.toString()), - ), - backgroundColor: Colors.red, - ), - ); + setState(() { + _isSavingRadioSettings = false; + _radioSettingsError = AppLocalizations.of( + context, + )!.failedToSave(e.toString()); + }); } } } @@ -698,9 +718,7 @@ class _DeviceConfigScreenState extends State { title: AppLocalizations.of( context, )!.telemetryAndLocationSharing, - description: _telemetryEnabled - ? 'Your location is shared with nearby devices.' - : 'Your location is not being shared.', + description: 'Share your location with nearby devices.', accentColor: _telemetryEnabled ? colorScheme.primary : colorScheme.onSurfaceVariant, @@ -709,6 +727,8 @@ class _DeviceConfigScreenState extends State { onChanged: (value) { setState(() { _telemetryEnabled = value; + _publicInfoSaved = false; + _publicInfoError = null; }); }, ), @@ -716,6 +736,7 @@ class _DeviceConfigScreenState extends State { const SizedBox(height: 18), TextField( controller: _nameController, + onChanged: (_) => _markPublicInfoDirty(), decoration: InputDecoration( labelText: 'Device name', border: OutlineInputBorder( @@ -763,6 +784,7 @@ class _DeviceConfigScreenState extends State { child: _CompactCoordinateField( controller: _latController, label: 'Latitude', + onChanged: (_) => _markPublicInfoDirty(), ), ), const SizedBox(width: 8), @@ -770,6 +792,7 @@ class _DeviceConfigScreenState extends State { child: _CompactCoordinateField( controller: _lonController, label: 'Longitude', + onChanged: (_) => _markPublicInfoDirty(), ), ), const SizedBox(width: 8), @@ -787,12 +810,22 @@ class _DeviceConfigScreenState extends State { ), ], const SizedBox(height: 18), + if (_publicInfoError != null) ...[ + Text( + _publicInfoError!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 10), + ], SizedBox( width: double.infinity, - child: FilledButton.icon( - onPressed: _savePublicInfo, - icon: const Icon(Icons.save_outlined), - label: const Text('Save public info'), + child: _SaveActionButton( + onPressed: _isSavingPublicInfo ? null : _savePublicInfo, + isSaving: _isSavingPublicInfo, + isSaved: _publicInfoSaved, + label: 'Save public info', ), ), ], @@ -839,10 +872,13 @@ class _DeviceConfigScreenState extends State { setState(() { _selectedRadioPreset = null; _showCustomRadioSettings = true; + _radioSettingsSaved = false; + _radioSettingsError = null; }); return; } _applyRadioPreset(preset); + _markRadioSettingsDirty(); }, ), const SizedBox(height: 16), @@ -900,6 +936,7 @@ class _DeviceConfigScreenState extends State { _selectedRadioPreset = null; }); } + _markRadioSettingsDirty(); }, ), const SizedBox(height: 16), @@ -927,6 +964,7 @@ class _DeviceConfigScreenState extends State { _selectedBandwidth = newValue; _selectedRadioPreset = null; }); + _markRadioSettingsDirty(); } }, ), @@ -957,6 +995,7 @@ class _DeviceConfigScreenState extends State { _selectedSpreadingFactor = newValue; _selectedRadioPreset = null; }); + _markRadioSettingsDirty(); } }, ), @@ -987,12 +1026,14 @@ class _DeviceConfigScreenState extends State { _selectedCodingRate = newValue; _selectedRadioPreset = null; }); + _markRadioSettingsDirty(); } }, ), const SizedBox(height: 16), TextField( controller: _txPowerController, + onChanged: (_) => _markRadioSettingsDirty(), decoration: InputDecoration( labelText: AppLocalizations.of( context, @@ -1027,18 +1068,32 @@ class _DeviceConfigScreenState extends State { onChanged: (value) { setState(() { _repeatEnabled = value; + _radioSettingsSaved = false; + _radioSettingsError = null; }); }, ), ), ], const SizedBox(height: 18), + if (_radioSettingsError != null) ...[ + Text( + _radioSettingsError!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 10), + ], SizedBox( width: double.infinity, - child: FilledButton.icon( - onPressed: _saveRadioSettings, - icon: const Icon(Icons.save_outlined), - label: const Text('Save radio settings'), + child: _SaveActionButton( + onPressed: _isSavingRadioSettings + ? null + : _saveRadioSettings, + isSaving: _isSavingRadioSettings, + isSaved: _radioSettingsSaved, + label: 'Save radio settings', ), ), ], @@ -1381,16 +1436,19 @@ class _StorageStat extends StatelessWidget { class _CompactCoordinateField extends StatelessWidget { final TextEditingController controller; final String label; + final ValueChanged? onChanged; const _CompactCoordinateField({ required this.controller, required this.label, + this.onChanged, }); @override Widget build(BuildContext context) { return TextField( controller: controller, + onChanged: onChanged, decoration: InputDecoration( labelText: label, border: OutlineInputBorder(borderRadius: BorderRadius.circular(18)), @@ -1512,6 +1570,53 @@ class _SelectedPresetCard extends StatelessWidget { } } +class _SaveActionButton extends StatelessWidget { + final VoidCallback? onPressed; + final bool isSaving; + final bool isSaved; + final String label; + + const _SaveActionButton({ + required this.onPressed, + required this.isSaving, + required this.isSaved, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return FilledButton( + onPressed: onPressed, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: isSaving + ? Row( + key: const ValueKey('saving'), + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2.2), + ), + SizedBox(width: 10), + Text('Saving...'), + ], + ) + : Row( + key: ValueKey(isSaved ? 'saved' : 'idle'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(isSaved ? Icons.check_rounded : Icons.save_outlined), + const SizedBox(width: 8), + Text(isSaved ? 'Saved' : label), + ], + ), + ), + ); + } +} + class _SettingHighlightCard extends StatelessWidget { final IconData icon; final String title;