Add Flutter live traffic monitor

This commit is contained in:
Janez T
2026-03-12 20:19:24 +01:00
parent 69ec2dfdb5
commit c36d63a0ad

View File

@@ -170,6 +170,12 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
bool _telemetryEnabled = false; bool _telemetryEnabled = false;
bool _repeatEnabled = false; bool _repeatEnabled = false;
bool _showCustomRadioSettings = 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'; String _selectedBandwidth = '62.5 kHz';
int _selectedSpreadingFactor = 8; int _selectedSpreadingFactor = 8;
int _selectedCodingRate = 8; int _selectedCodingRate = 8;
@@ -336,11 +342,35 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}); });
} }
void _markPublicInfoDirty() {
if (_publicInfoSaved || _publicInfoError != null) {
setState(() {
_publicInfoSaved = false;
_publicInfoError = null;
});
}
}
void _markRadioSettingsDirty() {
if (_radioSettingsSaved || _radioSettingsError != null) {
setState(() {
_radioSettingsSaved = false;
_radioSettingsError = null;
});
}
}
Future<void> _savePublicInfo() async { Future<void> _savePublicInfo() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo; final deviceInfo = connectionProvider.deviceInfo;
final validator = ValidationService(); final validator = ValidationService();
setState(() {
_isSavingPublicInfo = true;
_publicInfoSaved = false;
_publicInfoError = null;
});
try { try {
// Save name // Save name
if (_nameController.text.isNotEmpty) { if (_nameController.text.isNotEmpty) {
@@ -353,12 +383,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final latResult = validator.parseLatitude(_latController.text); final latResult = validator.parseLatitude(_latController.text);
if (!latResult.isSuccess) { if (!latResult.isSuccess) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( _publicInfoError = latResult.errorMessage!;
content: Text(latResult.errorMessage!), _isSavingPublicInfo = false;
backgroundColor: Colors.red, });
),
);
} }
return; return;
} }
@@ -366,12 +394,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final lonResult = validator.parseLongitude(_lonController.text); final lonResult = validator.parseLongitude(_lonController.text);
if (!lonResult.isSuccess) { if (!lonResult.isSuccess) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( _publicInfoError = lonResult.errorMessage!;
content: Text(lonResult.errorMessage!), _isSavingPublicInfo = false;
backgroundColor: Colors.red, });
),
);
} }
return; return;
} }
@@ -405,23 +431,19 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
await connectionProvider.refreshDeviceInfo(); await connectionProvider.refreshDeviceInfo();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( _isSavingPublicInfo = false;
content: Text(AppLocalizations.of(context)!.save), _publicInfoSaved = true;
backgroundColor: Colors.green, });
),
);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( _isSavingPublicInfo = false;
content: Text( _publicInfoError = AppLocalizations.of(
AppLocalizations.of(context)!.failedToSave(e.toString()), context,
), )!.failedToSave(e.toString());
backgroundColor: Colors.red, });
),
);
} }
} }
} }
@@ -431,17 +453,21 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final validator = ValidationService(); final validator = ValidationService();
final deviceInfo = connectionProvider.deviceInfo; final deviceInfo = connectionProvider.deviceInfo;
setState(() {
_isSavingRadioSettings = true;
_radioSettingsSaved = false;
_radioSettingsError = null;
});
try { try {
// Parse and validate frequency // Parse and validate frequency
final freqResult = validator.parseFrequency(_freqController.text); final freqResult = validator.parseFrequency(_freqController.text);
if (!freqResult.isSuccess) { if (!freqResult.isSuccess) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( _radioSettingsError = freqResult.errorMessage!;
content: Text(freqResult.errorMessage!), _isSavingRadioSettings = false;
backgroundColor: Colors.red, });
),
);
} }
return; return;
} }
@@ -453,12 +479,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
); );
if (!txPowerResult.isSuccess) { if (!txPowerResult.isSuccess) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( _radioSettingsError = txPowerResult.errorMessage!;
content: Text(txPowerResult.errorMessage!), _isSavingRadioSettings = false;
backgroundColor: Colors.red, });
),
);
} }
return; return;
} }
@@ -481,23 +505,19 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
await connectionProvider.refreshDeviceInfo(); await connectionProvider.refreshDeviceInfo();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( _isSavingRadioSettings = false;
content: Text(AppLocalizations.of(context)!.save), _radioSettingsSaved = true;
backgroundColor: Colors.green, });
),
);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar( _isSavingRadioSettings = false;
content: Text( _radioSettingsError = AppLocalizations.of(
AppLocalizations.of(context)!.failedToSave(e.toString()), context,
), )!.failedToSave(e.toString());
backgroundColor: Colors.red, });
),
);
} }
} }
} }
@@ -698,9 +718,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
title: AppLocalizations.of( title: AppLocalizations.of(
context, context,
)!.telemetryAndLocationSharing, )!.telemetryAndLocationSharing,
description: _telemetryEnabled description: 'Share your location with nearby devices.',
? 'Your location is shared with nearby devices.'
: 'Your location is not being shared.',
accentColor: _telemetryEnabled accentColor: _telemetryEnabled
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
@@ -709,6 +727,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_telemetryEnabled = value; _telemetryEnabled = value;
_publicInfoSaved = false;
_publicInfoError = null;
}); });
}, },
), ),
@@ -716,6 +736,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
const SizedBox(height: 18), const SizedBox(height: 18),
TextField( TextField(
controller: _nameController, controller: _nameController,
onChanged: (_) => _markPublicInfoDirty(),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Device name', labelText: 'Device name',
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -763,6 +784,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
child: _CompactCoordinateField( child: _CompactCoordinateField(
controller: _latController, controller: _latController,
label: 'Latitude', label: 'Latitude',
onChanged: (_) => _markPublicInfoDirty(),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -770,6 +792,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
child: _CompactCoordinateField( child: _CompactCoordinateField(
controller: _lonController, controller: _lonController,
label: 'Longitude', label: 'Longitude',
onChanged: (_) => _markPublicInfoDirty(),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -787,12 +810,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
], ],
const SizedBox(height: 18), const SizedBox(height: 18),
if (_publicInfoError != null) ...[
Text(
_publicInfoError!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
const SizedBox(height: 10),
],
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: _SaveActionButton(
onPressed: _savePublicInfo, onPressed: _isSavingPublicInfo ? null : _savePublicInfo,
icon: const Icon(Icons.save_outlined), isSaving: _isSavingPublicInfo,
label: const Text('Save public info'), isSaved: _publicInfoSaved,
label: 'Save public info',
), ),
), ),
], ],
@@ -839,10 +872,13 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
setState(() { setState(() {
_selectedRadioPreset = null; _selectedRadioPreset = null;
_showCustomRadioSettings = true; _showCustomRadioSettings = true;
_radioSettingsSaved = false;
_radioSettingsError = null;
}); });
return; return;
} }
_applyRadioPreset(preset); _applyRadioPreset(preset);
_markRadioSettingsDirty();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -900,6 +936,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_selectedRadioPreset = null; _selectedRadioPreset = null;
}); });
} }
_markRadioSettingsDirty();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -927,6 +964,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_selectedBandwidth = newValue; _selectedBandwidth = newValue;
_selectedRadioPreset = null; _selectedRadioPreset = null;
}); });
_markRadioSettingsDirty();
} }
}, },
), ),
@@ -957,6 +995,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_selectedSpreadingFactor = newValue; _selectedSpreadingFactor = newValue;
_selectedRadioPreset = null; _selectedRadioPreset = null;
}); });
_markRadioSettingsDirty();
} }
}, },
), ),
@@ -987,12 +1026,14 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_selectedCodingRate = newValue; _selectedCodingRate = newValue;
_selectedRadioPreset = null; _selectedRadioPreset = null;
}); });
_markRadioSettingsDirty();
} }
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _txPowerController, controller: _txPowerController,
onChanged: (_) => _markRadioSettingsDirty(),
decoration: InputDecoration( decoration: InputDecoration(
labelText: AppLocalizations.of( labelText: AppLocalizations.of(
context, context,
@@ -1027,18 +1068,32 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_repeatEnabled = value; _repeatEnabled = value;
_radioSettingsSaved = false;
_radioSettingsError = null;
}); });
}, },
), ),
), ),
], ],
const SizedBox(height: 18), const SizedBox(height: 18),
if (_radioSettingsError != null) ...[
Text(
_radioSettingsError!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
const SizedBox(height: 10),
],
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: _SaveActionButton(
onPressed: _saveRadioSettings, onPressed: _isSavingRadioSettings
icon: const Icon(Icons.save_outlined), ? null
label: const Text('Save radio settings'), : _saveRadioSettings,
isSaving: _isSavingRadioSettings,
isSaved: _radioSettingsSaved,
label: 'Save radio settings',
), ),
), ),
], ],
@@ -1381,16 +1436,19 @@ class _StorageStat extends StatelessWidget {
class _CompactCoordinateField extends StatelessWidget { class _CompactCoordinateField extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final String label; final String label;
final ValueChanged<String>? onChanged;
const _CompactCoordinateField({ const _CompactCoordinateField({
required this.controller, required this.controller,
required this.label, required this.label,
this.onChanged,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return TextField(
controller: controller, controller: controller,
onChanged: onChanged,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(18)), 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 { class _SettingHighlightCard extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;