From 81944bfd3c7ea3f992b3b2af23a1fa6752ab4131 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 5 Apr 2026 21:48:19 +0200 Subject: [PATCH] feat: Refresh generated API snapshots #0 --- ios/fastlane/report.xml | 8 +- lib/screens/device_config_screen.dart | 1477 ++++++++--------- lib/screens/settings_screen.dart | 1338 +++++++-------- .../traffic_stats_reporting_section.dart | 80 +- 4 files changed, 1392 insertions(+), 1511 deletions(-) diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index bc3e239..cfdf523 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 766acb6..bdad349 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -178,7 +178,7 @@ class _DeviceConfigScreenState extends State { late TextEditingController _lonController; late TextEditingController _freqController; late TextEditingController _txPowerController; - late TextEditingController _gpsIntervalController; + int? _gpsIntervalSeconds; late TextEditingController _autoAddMaxHopsController; late final ConnectionProvider _connectionProvider; @@ -199,6 +199,7 @@ class _DeviceConfigScreenState extends State { int? _gpsLastFixAgeSeconds; DateTime? _gpsStatsLoadedAt; Timer? _gpsStatsTicker; + Timer? _gpsRefreshTimer; bool _isSyncingDeviceTime = false; int? _selectedPathHashMode; bool _autoAddDiscoveredContactsEnabled = true; @@ -248,6 +249,10 @@ class _DeviceConfigScreenState extends State { if (!mounted || _gpsLastFixAgeSeconds == null) return; setState(() {}); }); + _gpsRefreshTimer = Timer.periodic(const Duration(seconds: 5), (_) { + if (!mounted) return; + _loadGpsMode(); + }); final deviceInfo = _connectionProvider.deviceInfo; _nameController = TextEditingController( @@ -271,7 +276,7 @@ class _DeviceConfigScreenState extends State { _txPowerController = TextEditingController( text: deviceInfo.txPower?.toString() ?? '20', ); - _gpsIntervalController = TextEditingController(); + // _gpsIntervalSeconds loaded via _loadGpsMode() _autoAddMaxHopsController = TextEditingController( text: (deviceInfo.autoAddMaxHops ?? 0).toString(), ); @@ -340,9 +345,10 @@ class _DeviceConfigScreenState extends State { _lonController.dispose(); _freqController.dispose(); _txPowerController.dispose(); - _gpsIntervalController.dispose(); + // _gpsIntervalSeconds is plain state, no dispose needed _autoAddMaxHopsController.dispose(); _gpsStatsTicker?.cancel(); + _gpsRefreshTimer?.cancel(); super.dispose(); } @@ -597,20 +603,11 @@ class _DeviceConfigScreenState extends State { await connectionProvider.setAdvertName(_nameController.text); } - final gpsIntervalText = _gpsIntervalController.text.trim(); - if (gpsIntervalText.isNotEmpty) { - final gpsInterval = int.tryParse(gpsIntervalText); - if (gpsInterval == null || gpsInterval < 0 || gpsInterval > 86400) { - if (mounted) { - setState(() { - _publicInfoError = - 'GPS interval must be a whole number between 0 and 86400 seconds.'; - _isSavingPublicInfo = false; - }); - } - return; - } - await connectionProvider.setCustomVar('gps_interval', gpsIntervalText); + if (_gpsIntervalSeconds != null) { + await connectionProvider.setCustomVar( + 'gps_interval', + _gpsIntervalSeconds.toString(), + ); } // Save stored coordinates only when the firmware advert policy uses prefs. @@ -829,9 +826,8 @@ class _DeviceConfigScreenState extends State { setState(() { _gpsEnabled = gpsValue != null ? gpsValue == '1' : null; _buzzerEnabled = buzzerValue != null ? buzzerValue == '1' : null; - if (gpsIntervalValue != null) { - _gpsIntervalController.text = gpsIntervalValue; - } + _gpsIntervalSeconds = int.tryParse(gpsIntervalValue ?? '') ?? + _gpsIntervalSeconds; _gpsHasFix = gpsFixValue != null ? gpsFixValue == '1' : null; _gpsSatelliteCount = int.tryParse(gpsSatsValue ?? ''); _gpsLatE6 = int.tryParse(gpsLatValue ?? ''); @@ -1277,12 +1273,12 @@ class _DeviceConfigScreenState extends State { final locationSet = _advertLocationPolicy != 0; return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), + appBar: AppBar(title: const Text('Device Settings')), body: ColoredBox( color: colorScheme.surface, child: SafeArea( child: ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), children: [ _ConfigHeroCard( title: @@ -1296,16 +1292,6 @@ class _DeviceConfigScreenState extends State { icon: Icons.my_location_rounded, emphasized: locationSet, ), - _HeroStatData( - label: AppLocalizations.of(context)!.frequency, - value: '${_freqController.text} MHz', - icon: Icons.settings_input_antenna_rounded, - ), - _HeroStatData( - label: AppLocalizations.of(context)!.bandwidth, - value: _selectedBandwidth, - icon: Icons.width_normal_rounded, - ), _HeroStatData( label: AppLocalizations.of(context)!.model, value: @@ -1315,47 +1301,11 @@ class _DeviceConfigScreenState extends State { ), ], ), - SizedBox(height: 20), - _ConfigSectionCard( - title: AppLocalizations.of(context)!.storage, - subtitle: AppLocalizations.of( - context, - )!.availableSpaceOnThisDevice, - icon: Icons.storage_rounded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: _StorageStat( - label: AppLocalizations.of(context)!.used, - value: _formatStorage( - deviceInfo.storageUsedKb ?? 0, - ), - ), - ), - SizedBox(width: 12), - Expanded( - child: _StorageStat( - label: AppLocalizations.of(context)!.total, - value: deviceInfo.storageTotalKb != null - ? _formatStorage(deviceInfo.storageTotalKb!) - : AppLocalizations.of(context)!.unknown, - ), - ), - ], - ), - const SizedBox(height: 16), - _StorageUsageMeter(deviceInfo: deviceInfo), - ], - ), - ), - SizedBox(height: 20), + SizedBox(height: 12), _ConfigSectionCard( title: 'Device info', subtitle: - 'Capabilities reported by the connected radio and maintenance tools.', + 'Capabilities, storage, and maintenance tools.', icon: Icons.info_outline_rounded, child: LayoutBuilder( builder: (context, constraints) { @@ -1367,8 +1317,8 @@ class _DeviceConfigScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( - spacing: 12, - runSpacing: 12, + spacing: 10, + runSpacing: 10, children: [ SizedBox( width: cardWidth, @@ -1413,61 +1363,96 @@ class _DeviceConfigScreenState extends State { compact: true, ), ), + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of(context)!.used, + value: _formatStorage( + deviceInfo.storageUsedKb ?? 0, + ), + compact: true, + ), + ), + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of(context)!.total, + value: deviceInfo.storageTotalKb != null + ? _formatStorage(deviceInfo.storageTotalKb!) + : AppLocalizations.of(context)!.unknown, + compact: true, + ), + ), ], ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: colorScheme.outlineVariant, + const SizedBox(height: 10), + _StorageUsageMeter(deviceInfo: deviceInfo), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isSyncingDeviceTime + ? null + : _syncDeviceTime, + icon: _isSyncingDeviceTime + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.schedule_rounded, size: 20), + label: Text( + _isSyncingDeviceTime + ? 'Syncing...' + : 'Sync device time', ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Clock maintenance', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 4), - Text( - 'Refresh the radio clock if room logins or message timestamps look off.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _isSyncingDeviceTime - ? null - : _syncDeviceTime, - icon: _isSyncingDeviceTime - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.schedule_rounded), - label: Text( - _isSyncingDeviceTime - ? 'Syncing time...' - : 'Sync device time', + ), + if (_buzzerEnabled != null) ...[ + const SizedBox(height: 10), + _SettingHighlightCard( + icon: _buzzerEnabled! + ? Icons.volume_up_rounded + : Icons.volume_off_rounded, + title: 'Buzzer alerts', + description: 'Onboard buzzer for radio alerts', + accentColor: _buzzerEnabled! + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: _buzzerLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Switch( + value: _buzzerEnabled!, + onChanged: _setBuzzerMode, ), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(46), - ), - ), - ), - ], + ), + ], + const SizedBox(height: 10), + _SettingHighlightCard( + icon: _multiAcksEnabled + ? Icons.mark_email_read_outlined + : Icons.mark_email_unread_outlined, + title: 'Multi-ACK mode', + description: 'Request extra acknowledgements', + accentColor: _multiAcksEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _multiAcksEnabled, + onChanged: (value) { + setState(() { + _multiAcksEnabled = value; + _markPublicInfoDirty(); + }); + }, ), ), ], @@ -1475,196 +1460,29 @@ class _DeviceConfigScreenState extends State { }, ), ), - SizedBox(height: 20), - _ConfigSectionCard( - title: AppLocalizations.of(context)!.autoDiscovery, - subtitle: - 'Control how the radio auto-adds discovered nodes to its contacts table.', - icon: Icons.person_search_rounded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SettingHighlightCard( - icon: _autoAddDiscoveredContactsEnabled - ? Icons.person_add_alt_1 - : Icons.person_add_disabled, - title: AppLocalizations.of( - context, - )!.enableAutomaticAdding, - description: - 'Turn this off to keep discoveries manual-only on the radio.', - accentColor: _autoAddDiscoveredContactsEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: Switch( - value: _autoAddDiscoveredContactsEnabled, - onChanged: (value) { - setState(() { - _autoAddDiscoveredContactsEnabled = value; - _markAutoDiscoverySettingsDirty(); - }); - }, - ), - ), - SizedBox(height: 18), - _SettingHighlightCard( - icon: Icons.person_outline_rounded, - title: AppLocalizations.of(context)!.autoaddUsers, - description: - 'Automatically store discovered user/chat nodes.', - accentColor: _autoAddUsersEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: Switch( - value: _autoAddUsersEnabled, - onChanged: _autoAddDiscoveredContactsEnabled - ? (value) { - setState(() { - _autoAddUsersEnabled = value; - _markAutoDiscoverySettingsDirty(); - }); - } - : null, - ), - ), - SizedBox(height: 14), - _SettingHighlightCard( - icon: Icons.router_outlined, - title: AppLocalizations.of(context)!.autoaddRepeaters, - description: - 'Automatically store discovered repeater nodes.', - accentColor: _autoAddRepeatersEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: Switch( - value: _autoAddRepeatersEnabled, - onChanged: _autoAddDiscoveredContactsEnabled - ? (value) { - setState(() { - _autoAddRepeatersEnabled = value; - _markAutoDiscoverySettingsDirty(); - }); - } - : null, - ), - ), - SizedBox(height: 14), - _SettingHighlightCard( - icon: Icons.meeting_room_outlined, - title: AppLocalizations.of(context)!.autoaddRoomServers, - description: - 'Automatically store discovered room/server nodes.', - accentColor: _autoAddRoomServersEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: Switch( - value: _autoAddRoomServersEnabled, - onChanged: _autoAddDiscoveredContactsEnabled - ? (value) { - setState(() { - _autoAddRoomServersEnabled = value; - _markAutoDiscoverySettingsDirty(); - }); - } - : null, - ), - ), - SizedBox(height: 14), - _SettingHighlightCard( - icon: Icons.sensors_outlined, - title: AppLocalizations.of(context)!.autoaddSensors, - description: - 'Automatically store discovered sensor nodes.', - accentColor: _autoAddSensorsEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: Switch( - value: _autoAddSensorsEnabled, - onChanged: _autoAddDiscoveredContactsEnabled - ? (value) { - setState(() { - _autoAddSensorsEnabled = value; - _markAutoDiscoverySettingsDirty(); - }); - } - : null, - ), - ), - SizedBox(height: 14), - _SettingHighlightCard( - icon: Icons.history_toggle_off_rounded, - title: AppLocalizations.of( - context, - )!.overwriteOldestWhenFull, - description: - 'Allow the radio to replace the oldest contact when storage is full.', - accentColor: _overwriteOldestAutoAddEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: Switch( - value: _overwriteOldestAutoAddEnabled, - onChanged: _autoAddDiscoveredContactsEnabled - ? (value) { - setState(() { - _overwriteOldestAutoAddEnabled = value; - _markAutoDiscoverySettingsDirty(); - }); - } - : null, - ), - ), - const SizedBox(height: 16), - TextField( - controller: _autoAddMaxHopsController, - onChanged: (_) => _markAutoDiscoverySettingsDirty(), - decoration: InputDecoration( - labelText: 'Auto-add max hops', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - helperText: - '0 means no limit. 1 keeps auto-add to direct neighbors only.', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 18), - if (_autoDiscoverySettingsError != null) ...[ - Text( - _autoDiscoverySettingsError!, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - const SizedBox(height: 10), - ], - SizedBox( - width: double.infinity, - child: _SaveActionButton( - onPressed: _isSavingAutoDiscoverySettings - ? null - : _saveAutoDiscoverySettings, - isSaving: _isSavingAutoDiscoverySettings, - isSaved: _autoDiscoverySettingsSaved, - label: AppLocalizations.of( - context, - )!.saveDiscoverySettings, - ), - ), - ], - ), - ), - SizedBox(height: 20), + SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.publicInfo, - subtitle: AppLocalizations.of( - context, - )!.chooseTheNameAndLocationThisDeviceShares, + subtitle: 'Name and telemetry shared with other devices.', icon: Icons.public_rounded, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextField( + controller: _nameController, + onChanged: (_) => _markPublicInfoDirty(), + decoration: InputDecoration( + labelText: 'Device name', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: 'Visible to other devices on the mesh', + ), + ), + const SizedBox(height: 10), _ConfigDropdownField( label: 'Base telemetry', value: _baseTelemetryMode, @@ -1684,7 +1502,7 @@ class _DeviceConfigScreenState extends State { }); }, ), - const SizedBox(height: 16), + const SizedBox(height: 10), _ConfigDropdownField( label: 'Location telemetry', value: _locationTelemetryMode, @@ -1704,7 +1522,7 @@ class _DeviceConfigScreenState extends State { }); }, ), - const SizedBox(height: 16), + const SizedBox(height: 10), _ConfigDropdownField( label: 'Environmental telemetry', value: _environmentTelemetryMode, @@ -1724,7 +1542,36 @@ class _DeviceConfigScreenState extends State { }); }, ), - const SizedBox(height: 16), + const SizedBox(height: 12), + if (_publicInfoError != null) ...[ + Text( + _publicInfoError!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 10), + ], + SizedBox( + width: double.infinity, + child: _SaveActionButton( + onPressed: _isSavingPublicInfo ? null : _savePublicInfo, + isSaving: _isSavingPublicInfo, + isSaved: _publicInfoSaved, + label: AppLocalizations.of(context)!.savePublicInfo, + ), + ), + ], + ), + ), + SizedBox(height: 12), + _ConfigSectionCard( + title: 'GPS', + subtitle: 'Location sharing, hardware, and update interval.', + icon: Icons.gps_fixed, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ _ConfigDropdownField( label: 'GPS advert policy', value: _advertLocationPolicy, @@ -1748,12 +1595,11 @@ class _DeviceConfigScreenState extends State { }, ), if (_gpsEnabled != null) ...[ - const SizedBox(height: 12), + const SizedBox(height: 8), _SettingHighlightCard( icon: _gpsEnabled! ? Icons.gps_fixed : Icons.gps_off, title: AppLocalizations.of(context)!.gpsModule, - description: - 'Enable or disable the onboard GPS hardware.', + description: 'Onboard GPS hardware', accentColor: _gpsEnabled! ? colorScheme.primary : colorScheme.onSurfaceVariant, @@ -1770,7 +1616,7 @@ class _DeviceConfigScreenState extends State { onChanged: _setGpsMode, ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), _GpsDiagnosticsCard( fixValue: _formatGpsFixValue(), satellitesValue: @@ -1781,91 +1627,49 @@ class _DeviceConfigScreenState extends State { ), ), ], - if (_buzzerEnabled != null) ...[ - const SizedBox(height: 12), - _SettingHighlightCard( - icon: _buzzerEnabled! - ? Icons.volume_up_rounded - : Icons.volume_off_rounded, - title: 'Buzzer alerts', - description: - 'Enable or mute the onboard buzzer for radio alerts.', - accentColor: _buzzerEnabled! - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: _buzzerLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : Switch( - value: _buzzerEnabled!, - onChanged: _setBuzzerMode, - ), - ), - ], - const SizedBox(height: 16), - TextField( - controller: _gpsIntervalController, - onChanged: (_) => _markPublicInfoDirty(), + const SizedBox(height: 10), + DropdownButtonFormField( + key: ValueKey('gps-interval-$_gpsIntervalSeconds'), + initialValue: _gpsIntervalSeconds, decoration: InputDecoration( - labelText: 'GPS interval (seconds)', + labelText: 'GPS interval', border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(14), ), filled: true, + isDense: true, fillColor: colorScheme.surfaceContainerLowest, - helperText: - 'Firmware supports 0-86400 seconds. Older builds may not report the current value back.', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 12), - _SettingHighlightCard( - icon: _multiAcksEnabled - ? Icons.mark_email_read_outlined - : Icons.mark_email_unread_outlined, - title: 'Multi-ACK mode', - description: - 'Ask the radio to request extra acknowledgements when the firmware supports it.', - accentColor: _multiAcksEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: Switch( - value: _multiAcksEnabled, - onChanged: (value) { - setState(() { - _multiAcksEnabled = value; - _markPublicInfoDirty(); - }); - }, - ), - ), - const SizedBox(height: 18), - TextField( - controller: _nameController, - onChanged: (_) => _markPublicInfoDirty(), - decoration: InputDecoration( - labelText: 'Device name', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - helperText: - 'This is the name other devices will see on the mesh.', ), + isExpanded: true, + items: const [ + DropdownMenuItem(value: null, child: Text('Not set')), + DropdownMenuItem(value: 0, child: Text('Off (0 s)')), + DropdownMenuItem(value: 5, child: Text('5 seconds')), + DropdownMenuItem(value: 10, child: Text('10 seconds')), + DropdownMenuItem(value: 15, child: Text('15 seconds')), + DropdownMenuItem(value: 30, child: Text('30 seconds')), + DropdownMenuItem(value: 60, child: Text('1 minute')), + DropdownMenuItem(value: 120, child: Text('2 minutes')), + DropdownMenuItem(value: 300, child: Text('5 minutes')), + DropdownMenuItem(value: 600, child: Text('10 minutes')), + DropdownMenuItem(value: 900, child: Text('15 minutes')), + DropdownMenuItem(value: 1800, child: Text('30 minutes')), + DropdownMenuItem(value: 3600, child: Text('1 hour')), + ], + onChanged: (value) { + setState(() { + _gpsIntervalSeconds = value; + _markPublicInfoDirty(); + }); + }, ), if (_advertLocationPolicy == 2) ...[ - const SizedBox(height: 16), + const SizedBox(height: 10), Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(22), + borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( @@ -1873,18 +1677,11 @@ class _DeviceConfigScreenState extends State { children: [ Text( 'Saved coordinates', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 4), - Text( - 'These coordinates are used when advert policy is set to saved coordinates.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 14), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -1916,45 +1713,26 @@ class _DeviceConfigScreenState extends State { ), ), ] else if (_advertLocationPolicy == 1) ...[ - const SizedBox(height: 16), + const SizedBox(height: 8), Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(22), + borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Text( - 'The firmware will advertise the live GPS fix from the onboard sensor manager when available.', + 'Firmware will advertise the live GPS fix when available.', style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), ], - 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: _SaveActionButton( - onPressed: _isSavingPublicInfo ? null : _savePublicInfo, - isSaving: _isSavingPublicInfo, - isSaved: _publicInfoSaved, - label: AppLocalizations.of(context)!.savePublicInfo, - ), - ), ], ), ), - SizedBox(height: 20), + SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.radioSettings, subtitle: AppLocalizations.of( @@ -1970,12 +1748,11 @@ class _DeviceConfigScreenState extends State { decoration: InputDecoration( labelText: 'Radio preset', border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(14), ), filled: true, + isDense: true, fillColor: colorScheme.surfaceContainerLowest, - helperText: - 'Start with an official preset, or switch to custom settings below.', ), isExpanded: true, items: [ @@ -2011,215 +1788,238 @@ class _DeviceConfigScreenState extends State { if (_selectedRadioPreset != null) _SelectedPresetCard(preset: _selectedRadioPreset!), const SizedBox(height: 8), - Theme( - data: theme.copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - tilePadding: EdgeInsets.zero, - childrenPadding: EdgeInsets.zero, - initiallyExpanded: _showCustomRadioSettings, - onExpansionChanged: (expanded) { - setState(() { - _showCustomRadioSettings = expanded; - if (expanded) { - _selectedRadioPreset = null; - } - }); - }, - title: Text( - 'Custom settings', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - ), + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () { + setState(() { + _showCustomRadioSettings = + !_showCustomRadioSettings; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 4, ), - subtitle: Text( - 'Adjust frequency, bandwidth, spreading factor, coding rate, and power.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - children: [ - const SizedBox(height: 12), - TextField( - controller: _freqController, - decoration: InputDecoration( - labelText: AppLocalizations.of( - context, - )!.frequencyMHz, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Custom settings', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + _showCustomRadioSettings + ? 'Frequency, bandwidth, SF, and CR' + : 'Tap to fine-tune radio parameters', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], ), - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - helperText: - 'Enter the channel frequency, for example 869.618.', ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - onChanged: (_) { - setState(_syncRadioPresetSelection); - _markRadioSettingsDirty(); - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - key: ValueKey('bandwidth-$_selectedBandwidth'), - initialValue: _selectedBandwidth, - decoration: InputDecoration( - labelText: AppLocalizations.of( - context, - )!.bandwidth, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - ), - items: _bandwidthOptions.map((String value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { - setState(() { - _selectedBandwidth = newValue; - _syncRadioPresetSelection(); - }); - _markRadioSettingsDirty(); - } - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - key: ValueKey( - 'spreading-factor-$_selectedSpreadingFactor', - ), - initialValue: _selectedSpreadingFactor, - decoration: InputDecoration( - labelText: AppLocalizations.of( - context, - )!.spreadingFactor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - ), - items: List.generate(6, (index) => index + 7).map(( - int value, - ) { - return DropdownMenuItem( - value: value, - child: Text(value.toString()), - ); - }).toList(), - onChanged: (int? newValue) { - if (newValue != null) { - setState(() { - _selectedSpreadingFactor = newValue; - _syncRadioPresetSelection(); - }); - _markRadioSettingsDirty(); - } - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - key: ValueKey('coding-rate-$_selectedCodingRate'), - initialValue: _selectedCodingRate, - decoration: InputDecoration( - labelText: AppLocalizations.of( - context, - )!.codingRate, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - ), - items: List.generate(4, (index) => index + 5).map(( - int value, - ) { - return DropdownMenuItem( - value: value, - child: Text(value.toString()), - ); - }).toList(), - onChanged: (int? newValue) { - if (newValue != null) { - setState(() { - _selectedCodingRate = newValue; - _syncRadioPresetSelection(); - }); - _markRadioSettingsDirty(); - } - }, - ), - const SizedBox(height: 16), - TextField( - controller: _txPowerController, - onChanged: (_) => _markRadioSettingsDirty(), - decoration: InputDecoration( - labelText: AppLocalizations.of( - context, - )!.txPowerDbm, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - helperText: AppLocalizations.of( - context, - )!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), - ), - keyboardType: TextInputType.number, - ), - if (_selectedPathHashMode != null) ...[ - const SizedBox(height: 16), - DropdownButtonFormField( - key: ValueKey('path-hash-$_selectedPathHashMode'), - initialValue: _selectedPathHashMode, - decoration: InputDecoration( - labelText: 'Advert path hash size', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLowest, - helperText: - 'Controls the low-level hash size used in adverts and flood paths.', - ), - items: const [ - DropdownMenuItem( - value: 0, - child: Text('1 byte (mode 0)'), - ), - DropdownMenuItem( - value: 1, - child: Text('2 bytes (mode 1)'), - ), - DropdownMenuItem( - value: 2, - child: Text('3 bytes (mode 2)'), - ), - ], - onChanged: (int? newValue) { - if (newValue != null) { - setState(() { - _selectedPathHashMode = newValue; - }); - _markRadioSettingsDirty(); - } - }, + Icon( + _showCustomRadioSettings + ? Icons.expand_less + : Icons.expand_more, + color: colorScheme.onSurfaceVariant, ), ], - ], + ), ), ), + if (_showCustomRadioSettings) ...[ + const SizedBox(height: 12), + TextField( + controller: _freqController, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.frequencyMHz, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + isDense: true, + helperText: 'e.g. 869.618', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (_) { + setState(_syncRadioPresetSelection); + _markRadioSettingsDirty(); + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('bandwidth-$_selectedBandwidth'), + initialValue: _selectedBandwidth, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.bandwidth, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: _bandwidthOptions.map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedBandwidth = newValue; + _syncRadioPresetSelection(); + }); + _markRadioSettingsDirty(); + } + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey( + 'spreading-factor-$_selectedSpreadingFactor', + ), + initialValue: _selectedSpreadingFactor, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.spreadingFactor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: List.generate(6, (index) => index + 7).map(( + int value, + ) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedSpreadingFactor = newValue; + _syncRadioPresetSelection(); + }); + _markRadioSettingsDirty(); + } + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('coding-rate-$_selectedCodingRate'), + initialValue: _selectedCodingRate, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.codingRate, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: List.generate(4, (index) => index + 5).map(( + int value, + ) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedCodingRate = newValue; + _syncRadioPresetSelection(); + }); + _markRadioSettingsDirty(); + } + }, + ), + ], + const SizedBox(height: 12), + TextField( + controller: _txPowerController, + onChanged: (_) => _markRadioSettingsDirty(), + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.txPowerDbm, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: AppLocalizations.of( + context, + )!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), + ), + keyboardType: TextInputType.number, + ), + if (_selectedPathHashMode != null) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('path-hash-$_selectedPathHashMode'), + initialValue: _selectedPathHashMode, + decoration: InputDecoration( + labelText: 'Advert path hash size', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: + 'Hash size used in adverts and flood paths', + ), + items: const [ + DropdownMenuItem( + value: 0, + child: Text('1 byte (mode 0)'), + ), + DropdownMenuItem( + value: 1, + child: Text('2 bytes (mode 1)'), + ), + DropdownMenuItem( + value: 2, + child: Text('3 bytes (mode 2)'), + ), + ], + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedPathHashMode = newValue; + }); + _markRadioSettingsDirty(); + } + }, + ), + ], if (deviceInfo.clientRepeat != null) ...[ - SizedBox(height: 16), + SizedBox(height: 10), _SettingHighlightCard( icon: Icons.repeat_rounded, title: AppLocalizations.of( @@ -2267,7 +2067,141 @@ class _DeviceConfigScreenState extends State { ], ), ), - SizedBox(height: 20), + SizedBox(height: 12), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.autoDiscovery, + subtitle: 'How the radio auto-adds discovered nodes.', + icon: Icons.person_search_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingHighlightCard( + icon: _autoAddDiscoveredContactsEnabled + ? Icons.person_add_alt_1 + : Icons.person_add_disabled, + title: AppLocalizations.of( + context, + )!.enableAutomaticAdding, + description: 'Off = manual-only discovery', + accentColor: _autoAddDiscoveredContactsEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _autoAddDiscoveredContactsEnabled, + onChanged: (value) { + setState(() { + _autoAddDiscoveredContactsEnabled = value; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + ), + const SizedBox(height: 8), + _AutoDiscoveryToggle( + icon: Icons.person_outline_rounded, + label: AppLocalizations.of(context)!.autoaddUsers, + value: _autoAddUsersEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _autoAddUsersEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + _AutoDiscoveryToggle( + icon: Icons.router_outlined, + label: AppLocalizations.of(context)!.autoaddRepeaters, + value: _autoAddRepeatersEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _autoAddRepeatersEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + _AutoDiscoveryToggle( + icon: Icons.meeting_room_outlined, + label: AppLocalizations.of(context)!.autoaddRoomServers, + value: _autoAddRoomServersEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _autoAddRoomServersEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + _AutoDiscoveryToggle( + icon: Icons.sensors_outlined, + label: AppLocalizations.of(context)!.autoaddSensors, + value: _autoAddSensorsEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _autoAddSensorsEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + _AutoDiscoveryToggle( + icon: Icons.history_toggle_off_rounded, + label: AppLocalizations.of( + context, + )!.overwriteOldestWhenFull, + value: _overwriteOldestAutoAddEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _overwriteOldestAutoAddEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + const SizedBox(height: 10), + TextField( + controller: _autoAddMaxHopsController, + onChanged: (_) => _markAutoDiscoverySettingsDirty(), + decoration: InputDecoration( + labelText: 'Auto-add max hops', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: '0 = no limit, 1 = direct neighbors only', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + if (_autoDiscoverySettingsError != null) ...[ + Text( + _autoDiscoverySettingsError!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 8), + ], + SizedBox( + width: double.infinity, + child: _SaveActionButton( + onPressed: _isSavingAutoDiscoverySettings + ? null + : _saveAutoDiscoverySettings, + isSaving: _isSavingAutoDiscoverySettings, + isSaved: _autoDiscoverySettingsSaved, + label: AppLocalizations.of( + context, + )!.saveDiscoverySettings, + ), + ), + ], + ), + ), + SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.dangerZone, subtitle: AppLocalizations.of( @@ -2277,46 +2211,6 @@ class _DeviceConfigScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: colorScheme.error), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.delete_forever_rounded, - color: colorScheme.error, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Wipe data on device', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - 'Erase contacts, keys, and radio settings from the connected MeshCore device and return it to factory defaults.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -2326,23 +2220,23 @@ class _DeviceConfigScreenState extends State { style: OutlinedButton.styleFrom( foregroundColor: colorScheme.error, side: BorderSide(color: colorScheme.error), - minimumSize: const Size.fromHeight(52), + minimumSize: const Size.fromHeight(44), ), icon: _isClearingContacts ? const SizedBox( - width: 18, - height: 18, + width: 16, + height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) - : Icon(Icons.people_alt_outlined), + : const Icon(Icons.people_alt_outlined, size: 20), label: Text( AppLocalizations.of(context)!.clearAllContacts, ), ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -2352,23 +2246,23 @@ class _DeviceConfigScreenState extends State { style: OutlinedButton.styleFrom( foregroundColor: colorScheme.error, side: BorderSide(color: colorScheme.error), - minimumSize: const Size.fromHeight(52), + minimumSize: const Size.fromHeight(44), ), icon: _isClearingChannels ? const SizedBox( - width: 18, - height: 18, + width: 16, + height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) - : Icon(Icons.forum_outlined), + : const Icon(Icons.forum_outlined, size: 20), label: Text( AppLocalizations.of(context)!.clearAllChannels, ), ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton.icon( @@ -2378,9 +2272,9 @@ class _DeviceConfigScreenState extends State { style: FilledButton.styleFrom( backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError, - minimumSize: const Size.fromHeight(52), + minimumSize: const Size.fromHeight(44), ), - icon: Icon(Icons.delete_forever_rounded), + icon: const Icon(Icons.delete_forever_rounded, size: 20), label: Text( AppLocalizations.of(context)!.wipeDeviceData, ), @@ -2460,10 +2354,10 @@ class _ConfigHeroCard extends StatelessWidget { final colorScheme = theme.colorScheme; return Container( - padding: const EdgeInsets.all(22), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(28), + borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( @@ -2472,50 +2366,34 @@ class _ConfigHeroCard extends StatelessWidget { Row( children: [ Container( - width: 56, - height: 56, + width: 40, + height: 40, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.tune_rounded, + color: colorScheme.primary, + size: 22, ), - child: Icon(Icons.tune_rounded, color: colorScheme.primary), ), - const SizedBox(width: 14), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - 'Device settings', - style: theme.textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(height: 10), Text( title, - style: theme.textTheme.headlineSmall?.copyWith( + style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, color: colorScheme.onSurface, ), ), - const SizedBox(height: 4), Text( subtitle, - style: theme.textTheme.bodyMedium?.copyWith( + style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, - height: 1.35, ), ), ], @@ -2523,10 +2401,10 @@ class _ConfigHeroCard extends StatelessWidget { ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 12), Wrap( - spacing: 10, - runSpacing: 10, + spacing: 8, + runSpacing: 8, children: stats.map(_HeroStat.new).toList(), ), ], @@ -2566,33 +2444,32 @@ class _HeroStat extends StatelessWidget { : colorScheme.outlineVariant; return Container( - constraints: const BoxConstraints(minWidth: 140), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + constraints: const BoxConstraints(minWidth: 120), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(14), border: Border.all(color: borderColor), ), child: Row( children: [ - Icon(data.icon, size: 18, color: iconColor), - const SizedBox(width: 10), + Icon(data.icon, size: 16, color: iconColor), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( data.label, - style: theme.textTheme.labelMedium?.copyWith( + style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 2), Text( data.value, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( + style: theme.textTheme.labelLarge?.copyWith( color: colorScheme.onSurface, fontWeight: FontWeight.w800, ), @@ -2629,41 +2506,30 @@ class _ConfigSectionCard extends StatelessWidget { elevation: 0, surfaceTintColor: Colors.transparent, clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Padding( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), - ), - child: Icon(icon, color: colorScheme.primary), - ), - const SizedBox(width: 12), + Icon(icon, color: colorScheme.primary, size: 22), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: theme.textTheme.titleLarge?.copyWith( + style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 3), Text( subtitle, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, - height: 1.35, ), ), ], @@ -2671,7 +2537,7 @@ class _ConfigSectionCard extends StatelessWidget { ), ], ), - const SizedBox(height: 18), + const SizedBox(height: 12), child, ], ), @@ -2700,7 +2566,7 @@ class _ConfigDropdownField extends StatelessWidget { initialValue: value, decoration: InputDecoration( labelText: label, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(20)), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)), filled: true, fillColor: colorScheme.surfaceContainerLowest, ), @@ -2725,10 +2591,10 @@ class _StorageStat extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( - padding: EdgeInsets.all(compact ? 12 : 14), + padding: EdgeInsets.all(compact ? 8 : 10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(12), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( @@ -2739,16 +2605,15 @@ class _StorageStat extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant, - fontSize: compact ? 13 : null, + fontSize: compact ? 11 : 12, ), ), - SizedBox(height: compact ? 4 : 6), Text( value, style: TextStyle( fontWeight: FontWeight.w800, color: colorScheme.onSurface, - fontSize: compact ? 17 : null, + fontSize: compact ? 14 : 15, ), ), ], @@ -2802,46 +2667,25 @@ class _StorageUsageMeter extends StatelessWidget { 1.0, ); - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.storage_rounded, color: colorScheme.primary, size: 18), - const SizedBox(width: 8), - Text( - 'Storage usage', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: percent, + minHeight: 8, + backgroundColor: colorScheme.surface, ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(999), - child: LinearProgressIndicator( - value: percent, - minHeight: 10, - backgroundColor: colorScheme.surface, - ), + ), + const SizedBox(height: 4), + Text( + '${(deviceInfo.storageUsedPercent ?? 0).toStringAsFixed(0)}% used', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, ), - const SizedBox(height: 10), - Text( - '${(deviceInfo.storageUsedPercent ?? 0).toStringAsFixed(0)}% of storage used', - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ], - ), + ), + ], ); } } @@ -2861,7 +2705,7 @@ class _SelectedPresetCard extends StatelessWidget { padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( @@ -2955,47 +2799,35 @@ class _SettingHighlightCard extends StatelessWidget { final colorScheme = theme.colorScheme; return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(22), + borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), - ), - child: Icon(icon, color: accentColor), - ), - const SizedBox(width: 12), + Icon(icon, color: accentColor, size: 22), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 4), Text( description, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, - height: 1.35, ), ), ], ), ), - const SizedBox(width: 8), trailing, ], ), @@ -3023,10 +2855,10 @@ class _GpsDiagnosticsCard extends StatelessWidget { return Container( width: double.infinity, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(22), + borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( @@ -3037,24 +2869,24 @@ class _GpsDiagnosticsCard extends StatelessWidget { Icon( Icons.location_searching_rounded, color: colorScheme.primary, - size: 18, + size: 16, ), - const SizedBox(width: 8), + const SizedBox(width: 6), Text( 'GPS diagnostics', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 6), _GpsDiagnosticsRow(label: 'Fix', value: fixValue), - const SizedBox(height: 8), + const SizedBox(height: 4), _GpsDiagnosticsRow(label: 'Satellites', value: satellitesValue), - const SizedBox(height: 8), + const SizedBox(height: 4), _GpsDiagnosticsRow(label: 'Last fix', value: lastFixValue), - const SizedBox(height: 8), + const SizedBox(height: 4), _GpsDiagnosticsRow( label: 'Location', value: locationValue, @@ -3112,6 +2944,35 @@ class _GpsDiagnosticsRow extends StatelessWidget { } } +class _AutoDiscoveryToggle extends StatelessWidget { + final IconData icon; + final String label; + final bool value; + final bool enabled; + final ValueChanged onChanged; + + const _AutoDiscoveryToggle({ + required this.icon, + required this.label, + required this.value, + required this.enabled, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return SwitchListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + secondary: Icon(icon, size: 20), + title: Text(label), + value: value, + onChanged: enabled ? onChanged : null, + ); + } +} + class _RadioPreset { final String id; final String label; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e7a7833..68ff8ef 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1221,282 +1221,295 @@ class _SettingsScreenState extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), - body: ListView( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), - children: [ - _buildSectionHeader('Appearance'), - _buildSettingsCard([ + appBar: AppBar(title: Text(l10n.settings)), + body: ColoredBox( + color: colorScheme.surface, + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + // ── Appearance ── + _buildSection( + icon: Icons.palette_rounded, + title: 'Appearance', + subtitle: 'Theme, language, and display preferences', + children: [ ListTile( - leading: Icon(Icons.palette), - title: Text(AppLocalizations.of(context)!.theme), - subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)), - trailing: const Icon(Icons.chevron_right), + dense: true, + leading: const Icon(Icons.palette, size: 20), + title: Text(l10n.theme), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppTheme.getThemeDisplayName(_selectedTheme), + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), onTap: () => _showThemeDialog(), ), ListTile( - leading: Icon(Icons.language), - title: Text(AppLocalizations.of(context)!.language), - subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)), - trailing: const Icon(Icons.chevron_right), + dense: true, + leading: const Icon(Icons.language, size: 20), + title: Text(l10n.language), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocalePreferences.getDisplayName(_selectedLocale), + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), onTap: () => _showLanguageDialog(), ), SwitchListTile( - secondary: Icon(Icons.radar), - title: Text(AppLocalizations.of(context)!.showRxTxIndicators), - subtitle: Text( - AppLocalizations.of(context)!.displayPacketActivity, - ), + dense: true, + secondary: const Icon(Icons.radar, size: 20), + title: Text(l10n.showRxTxIndicators), value: _showRxTxIndicators, onChanged: (value) async { - setState(() { - _showRxTxIndicators = value; - }); + setState(() => _showRxTxIndicators = value); await _saveRxTxPreference(value); }, ), - ]), - - _buildSectionHeader('Notifications'), - _buildSettingsCard([ - SwitchListTile( - secondary: Icon(Icons.chat_bubble_outline), - title: Text(AppLocalizations.of(context)!.messageNotifications), - subtitle: const Text( - 'Notify for incoming direct and channel messages', + Consumer( + builder: (context, appProvider, child) => ListTile( + dense: true, + leading: const Icon(Icons.format_size, size: 20), + title: Text(l10n.messageFontSize), + trailing: SizedBox( + width: 140, + child: Slider( + value: appProvider.messageFontScale, + min: 0.85, + max: 1.4, + divisions: 11, + label: '${(appProvider.messageFontScale * 100).round()}%', + onChanged: (value) { + appProvider.setMessageFontScale(value); + }, + ), + ), ), + ), + ]), + const SizedBox(height: 12), + + // ── Notifications ── + _buildSection( + icon: Icons.notifications_outlined, + title: 'Notifications', + subtitle: 'Control which alerts you receive', + children: [ + SwitchListTile( + dense: true, + secondary: const Icon(Icons.chat_bubble_outline, size: 20), + title: Text(l10n.messageNotifications), value: _messageNotificationsEnabled, onChanged: (value) async { - setState(() { - _messageNotificationsEnabled = value; - }); + setState(() => _messageNotificationsEnabled = value); await NotificationService().setMessageNotificationsEnabled( value, ); }, ), SwitchListTile( - secondary: Icon(Icons.warning_amber_outlined), - title: Text(AppLocalizations.of(context)!.sarAlerts), - subtitle: const Text( - 'Notify for incoming SAR markers such as found person or fire', - ), + dense: true, + secondary: const Icon(Icons.warning_amber_outlined, size: 20), + title: Text(l10n.sarAlerts), value: _sarNotificationsEnabled, onChanged: (value) async { - setState(() { - _sarNotificationsEnabled = value; - }); + setState(() => _sarNotificationsEnabled = value); await NotificationService().setSarNotificationsEnabled(value); }, ), SwitchListTile( - secondary: Icon(Icons.contact_page_outlined), - title: Text(AppLocalizations.of(context)!.discoveryNotifications), - subtitle: const Text( - 'Notify when new contacts appear in Discovery', - ), + dense: true, + secondary: const Icon(Icons.contact_page_outlined, size: 20), + title: Text(l10n.discoveryNotifications), value: _discoveryNotificationsEnabled, onChanged: (value) async { - setState(() { - _discoveryNotificationsEnabled = value; - }); + setState(() => _discoveryNotificationsEnabled = value); await NotificationService().setDiscoveryNotificationsEnabled( value, ); }, ), SwitchListTile( - secondary: Icon(Icons.system_update), - title: Text(AppLocalizations.of(context)!.updateNotifications), - subtitle: const Text( - 'Notify when a newer app version is available', - ), + dense: true, + secondary: const Icon(Icons.system_update, size: 20), + title: Text(l10n.updateNotifications), value: _updateNotificationsEnabled, onChanged: (value) async { - setState(() { - _updateNotificationsEnabled = value; - }); + setState(() => _updateNotificationsEnabled = value); await NotificationService().setUpdateNotificationsEnabled( value, ); }, ), SwitchListTile( - secondary: Icon(Icons.visibility_off_outlined), - title: Text(AppLocalizations.of(context)!.muteWhileAppIsOpen), - subtitle: const Text( - 'Do not show local notifications while the app is in the foreground', - ), + dense: true, + secondary: const Icon(Icons.visibility_off_outlined, size: 20), + title: Text(l10n.muteWhileAppIsOpen), + subtitle: const Text('Suppress notifications while in foreground'), value: _muteForegroundNotifications, onChanged: (value) async { - setState(() { - _muteForegroundNotifications = value; - }); + setState(() => _muteForegroundNotifications = value); await NotificationService().setMuteForegroundNotifications( value, ); }, ), ]), + const SizedBox(height: 12), - _buildSectionHeader('Navigation'), - _buildSettingsCard([ - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.map_outlined), - title: Text(AppLocalizations.of(context)!.disableMap), - subtitle: Text( - AppLocalizations.of(context)!.disableMapDescription, - ), + // ── Tabs & Navigation ── + Consumer( + builder: (context, appProvider, child) => _buildSection( + icon: Icons.dashboard_customize_rounded, + title: 'Tabs & Navigation', + subtitle: 'Choose which tabs and contact sections to show', + children: [ + SwitchListTile( + dense: true, + secondary: const Icon(Icons.map_outlined, size: 20), + title: Text(l10n.disableMap), value: !appProvider.isMapEnabled, onChanged: (value) async { await appProvider.toggleMapEnabled(!value); }, ), - ), - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.contacts_outlined), - title: Text(AppLocalizations.of(context)!.disableContacts), - subtitle: const Text( - 'Hide the contacts tab to simplify navigation', - ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.contacts_outlined, size: 20), + title: Text(l10n.disableContacts), value: !appProvider.isContactsEnabled, onChanged: (value) async { await appProvider.toggleContactsEnabled(!value); }, ), - ), - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.sensors), - title: Text(AppLocalizations.of(context)!.enableSensorsTab), - subtitle: const Text( - 'Show a dedicated tab for watched relay and node telemetry', - ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.sensors, size: 20), + title: Text(l10n.enableSensorsTab), value: appProvider.isSensorsEnabled, onChanged: (value) async { await appProvider.toggleSensorsEnabled(value); }, ), - ), - ]), - - _buildSectionHeader(AppLocalizations.of(context)!.contacts), - Consumer( - builder: (context, appProvider, child) => _buildSettingsCard([ - SwitchListTile( - secondary: const Icon(Icons.star_outline_rounded), - title: Text(AppLocalizations.of(context)!.favourites), - subtitle: const Text( - 'Show the favourites section in the contacts tab', + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 4), + child: Text( + 'Contacts tab sections', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), + ), + _buildCompactToggleRow( + icon: Icons.star_outline_rounded, + label: l10n.favourites, value: appProvider.isContactsSectionEnabled( ContactsTabSection.favourites, ), - onChanged: (value) async { - await appProvider.setContactsSectionEnabled( - ContactsTabSection.favourites, - value, - ); - }, - ), - SwitchListTile( - secondary: const Icon(Icons.people_alt_outlined), - title: Text(AppLocalizations.of(context)!.teamMembers), - subtitle: const Text( - 'Show direct team contacts in the contacts tab', + onChanged: (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.favourites, + v, ), + ), + _buildCompactToggleRow( + icon: Icons.people_alt_outlined, + label: l10n.teamMembers, value: appProvider.isContactsSectionEnabled( ContactsTabSection.teamMembers, ), - onChanged: (value) async { - await appProvider.setContactsSectionEnabled( - ContactsTabSection.teamMembers, - value, - ); - }, + onChanged: (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.teamMembers, + v, + ), ), - SwitchListTile( - secondary: const Icon(Icons.router_outlined), - title: Text(AppLocalizations.of(context)!.repeaters), - subtitle: const Text('Show repeater nodes in the contacts tab'), + _buildCompactToggleRow( + icon: Icons.router_outlined, + label: l10n.repeaters, value: appProvider.isContactsSectionEnabled( ContactsTabSection.repeaters, ), - onChanged: (value) async { - await appProvider.setContactsSectionEnabled( - ContactsTabSection.repeaters, - value, - ); - }, + onChanged: (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.repeaters, + v, + ), ), - SwitchListTile( - secondary: const Icon(Icons.sensors_outlined), - title: Text(AppLocalizations.of(context)!.sensors), - subtitle: const Text('Show sensor nodes in the contacts tab'), + _buildCompactToggleRow( + icon: Icons.sensors_outlined, + label: l10n.sensors, value: appProvider.isContactsSectionEnabled( ContactsTabSection.sensors, ), - onChanged: (value) async { - await appProvider.setContactsSectionEnabled( - ContactsTabSection.sensors, - value, - ); - }, + onChanged: (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.sensors, + v, + ), ), - SwitchListTile( - secondary: const Icon(Icons.meeting_room_outlined), - title: Text(AppLocalizations.of(context)!.rooms), - subtitle: const Text('Show rooms in the contacts tab'), + _buildCompactToggleRow( + icon: Icons.meeting_room_outlined, + label: l10n.rooms, value: appProvider.isContactsSectionEnabled( ContactsTabSection.rooms, ), - onChanged: (value) async { - await appProvider.setContactsSectionEnabled( - ContactsTabSection.rooms, - value, - ); - }, + onChanged: (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.rooms, + v, + ), ), - SwitchListTile( - secondary: const Icon(Icons.broadcast_on_personal_outlined), - title: Text(AppLocalizations.of(context)!.channels), - subtitle: const Text('Show channels in the contacts tab'), + _buildCompactToggleRow( + icon: Icons.broadcast_on_personal_outlined, + label: l10n.channels, value: appProvider.isContactsSectionEnabled( ContactsTabSection.channels, ), - onChanged: (value) async { - await appProvider.setContactsSectionEnabled( - ContactsTabSection.channels, - value, - ); - }, + onChanged: (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.channels, + v, + ), ), ]), ), + const SizedBox(height: 12), - _buildSectionHeader('Messaging'), - _buildSettingsCard([ + // ── Messaging ── + _buildSection( + icon: Icons.chat_rounded, + title: 'Messaging', + subtitle: 'Routing, retries, and destination lock', + children: [ ListTile( - leading: Icon(Icons.alt_route), - title: Text(AppLocalizations.of(context)!.routePathByteSize), - subtitle: Text( - '$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes', + dense: true, + leading: const Icon(Icons.alt_route, size: 20), + title: Text(l10n.routePathByteSize), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], ), - trailing: const Icon(Icons.chevron_right), onTap: _showRouteHashSizeDialog, ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.route), - title: Text( - AppLocalizations.of(context)!.nearestRepeaterFallback, - ), - subtitle: const Text( - 'After normal retries fail, try one final resend through the nearest repeater', - ), + dense: true, + secondary: const Icon(Icons.route, size: 20), + title: Text(l10n.nearestRepeaterFallback), + subtitle: const Text('Resend through nearest repeater on failure'), value: appProvider.nearestRelayFallbackEnabled, onChanged: (value) async { await appProvider.toggleNearestRelayFallbackEnabled(value); @@ -1505,22 +1518,23 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.route), - title: Text(AppLocalizations.of(context)!.clearPathOnMaxRetry), - subtitle: const Text( - 'Clear the route only after all retries and final router fallback fail', - ), + dense: true, + secondary: const Icon(Icons.cleaning_services, size: 20), + title: Text(l10n.clearPathOnMaxRetry), + subtitle: const Text('Clear route only after all retries fail'), value: appProvider.clearPathOnMaxRetry, onChanged: (value) async { await appProvider.toggleClearPathOnMaxRetry(value); }, ), ), + const Divider(height: 1), SwitchListTile( - secondary: const Icon(Icons.lock_outline), - title: const Text('Lock messages to one channel or room'), + dense: true, + secondary: const Icon(Icons.lock_outline, size: 20), + title: const Text('Lock destination'), subtitle: const Text( - 'Keep the Messages tab and composer fixed on one destination. Direct messages from Contacts still open as usual.', + 'Fix Messages tab to one channel or room', ), value: _messageDestinationLockEnabled, onChanged: (value) async { @@ -1544,183 +1558,93 @@ class _SettingsScreenState extends State { ); }, ), - Consumer( - builder: (context, contactsProvider, child) { - final options = _messageDestinationLockOptions( - contactsProvider, - ); - final selectedValue = - _selectedMessageDestinationLockValue(options); + if (_messageDestinationLockEnabled) + Consumer( + builder: (context, contactsProvider, child) { + final options = _messageDestinationLockOptions( + contactsProvider, + ); + final selectedValue = + _selectedMessageDestinationLockValue(options); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: DropdownButtonFormField( - key: ValueKey(selectedValue), - initialValue: selectedValue, - isExpanded: true, - decoration: const InputDecoration( - labelText: 'Locked channel or room', - prefixIcon: Icon(Icons.forum_outlined), - border: OutlineInputBorder(), - ), - items: [ - for (final contact in options) - DropdownMenuItem( - value: contact.publicKeyHex, - child: Text( - _messageDestinationLockLabel(context, contact), - overflow: TextOverflow.ellipsis, + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: DropdownButtonFormField( + key: ValueKey(selectedValue), + initialValue: selectedValue, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Locked channel or room', + prefixIcon: Icon(Icons.forum_outlined), + border: OutlineInputBorder(), + isDense: true, + ), + items: [ + for (final contact in options) + DropdownMenuItem( + value: contact.publicKeyHex, + child: Text( + _messageDestinationLockLabel(context, contact), + overflow: TextOverflow.ellipsis, + ), ), - ), - ], - onChanged: - _messageDestinationLockEnabled && options.isNotEmpty - ? (value) async { - if (value == null) { - return; + ], + onChanged: options.isNotEmpty + ? (value) async { + if (value == null) return; + final selectedContact = + options.where((contact) { + return contact.publicKeyHex == value; + }).firstOrNull; + if (selectedContact == null) return; + + await _setMessageDestinationLock( + enabled: true, + type: _messageDestinationLockTypeForContact( + selectedContact, + ), + recipientPublicKey: value, + ); } - - final selectedContact = options.where((contact) { - return contact.publicKeyHex == value; - }).firstOrNull; - if (selectedContact == null) { - return; - } - - await _setMessageDestinationLock( - enabled: true, - type: _messageDestinationLockTypeForContact( - selectedContact, - ), - recipientPublicKey: value, - ); - } - : null, - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.delete_sweep, color: Colors.red), - title: const Text( - 'Clear Messages', - style: TextStyle(color: Colors.red), + : null, + ), + ); + }, ), - subtitle: Text( - AppLocalizations.of(context)!.deleteAllStoredMessageHistory, - ), - onTap: _clearMessages, - ), - Consumer( - builder: (context, appProvider, child) => ListTile( - leading: Icon(Icons.format_size), - title: Text(AppLocalizations.of(context)!.messageFontSize), - subtitle: Text( - '${(appProvider.messageFontScale * 100).round()}% of default', - ), - trailing: SizedBox( - width: 150, - child: Slider( - value: appProvider.messageFontScale, - min: 0.85, - max: 1.4, - divisions: 11, - label: '${(appProvider.messageFontScale * 100).round()}%', - onChanged: (value) { - appProvider.setMessageFontScale(value); - }, - ), - ), - ), - ), ]), + const SizedBox(height: 12), - _buildSectionHeader('Tracing'), - _buildSettingsCard([ - ListTile( - leading: Icon(Icons.cloud_sync), - title: Text(AppLocalizations.of(context)!.onlineTraceDatabase), - subtitle: Text(_onlineTraceCacheSubtitle()), - ), - ListTile( - leading: Icon( - Icons.delete_sweep, - color: _isClearingOnlineTraceCache ? null : Colors.red, - ), - title: Text( - 'Clear online trace database', - style: TextStyle( - color: _isClearingOnlineTraceCache ? null : Colors.red, - ), - ), - subtitle: const Text( - 'Remove the 24-hour cached fallback used when local route matches are incomplete', - ), - enabled: !_isClearingOnlineTraceCache, - trailing: _isClearingOnlineTraceCache - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, - onTap: _isClearingOnlineTraceCache - ? null - : _clearOnlineTraceCache, - ), - ]), - - _buildSectionHeader('Map'), - _buildSettingsCard([ + // ── Map & Tracing ── + _buildSection( + icon: Icons.map_rounded, + title: 'Map', + subtitle: 'Display, markers, and trace database', + children: [ SwitchListTile( - secondary: Icon(Icons.explore), - title: Text(AppLocalizations.of(context)!.rotateMapWithHeading), - subtitle: const Text( - 'Rotate the map based on your compass or movement heading', - ), + dense: true, + secondary: const Icon(Icons.explore, size: 20), + title: Text(l10n.rotateMapWithHeading), value: _rotateMapWithHeading, onChanged: (value) async { - setState(() { - _rotateMapWithHeading = value; - }); + setState(() => _rotateMapWithHeading = value); await _saveMapPreference('map_rotate_with_heading', value); }, ), SwitchListTile( - secondary: Icon(Icons.bug_report_outlined), - title: Text(AppLocalizations.of(context)!.showMapDebugInfo), - subtitle: const Text( - 'Display extra map diagnostics and internal state overlays', - ), - value: _showMapDebugInfo, - onChanged: (value) async { - setState(() { - _showMapDebugInfo = value; - }); - await _saveMapPreference('map_show_debug_info', value); - }, - ), - SwitchListTile( - secondary: Icon(Icons.fullscreen), - title: Text(AppLocalizations.of(context)!.openMapInFullscreen), - subtitle: const Text( - 'Start the map tab in fullscreen mode by default', - ), + dense: true, + secondary: const Icon(Icons.fullscreen, size: 20), + title: Text(l10n.openMapInFullscreen), value: _openMapInFullscreen, onChanged: (value) async { - setState(() { - _openMapInFullscreen = value; - }); + setState(() => _openMapInFullscreen = value); await _saveMapPreference('map_fullscreen', value); }, ), Consumer( builder: (context, drawingProvider, child) => SwitchListTile( - secondary: Icon(Icons.fmd_good_outlined), - title: Text(AppLocalizations.of(context)!.showSarMarkersLabel), - subtitle: Text( - AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap, - ), + dense: true, + secondary: const Icon(Icons.fmd_good_outlined, size: 20), + title: Text(l10n.showSarMarkersLabel), value: drawingProvider.showSarMarkers, onChanged: (value) { drawingProvider.toggleSarMarkers(); @@ -1729,19 +1653,166 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, mapProvider, child) => SwitchListTile( - secondary: Icon(Icons.router_outlined), - title: Text(AppLocalizations.of(context)!.hideRepeatersOnMap), - subtitle: const Text( - 'Hide repeater contacts from the main map view', - ), + dense: true, + secondary: const Icon(Icons.router_outlined, size: 20), + title: Text(l10n.hideRepeatersOnMap), value: mapProvider.hideRepeatersOnMap, onChanged: (value) async { await mapProvider.setHideRepeatersOnMap(value); }, ), ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.bug_report_outlined, size: 20), + title: Text(l10n.showMapDebugInfo), + value: _showMapDebugInfo, + onChanged: (value) async { + setState(() => _showMapDebugInfo = value); + await _saveMapPreference('map_show_debug_info', value); + }, + ), + const Divider(height: 1), + ListTile( + dense: true, + leading: const Icon(Icons.cloud_sync, size: 20), + title: Text(l10n.onlineTraceDatabase), + subtitle: Text( + _onlineTraceCacheSubtitle(), + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: _isClearingOnlineTraceCache + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : TextButton( + onPressed: _clearOnlineTraceCache, + child: const Text( + 'Clear', + style: TextStyle(color: Colors.red), + ), + ), + ), ]), - _buildSectionHeader('Voice'), + const SizedBox(height: 12), + + // ── GPS & Location ── + _buildSection( + icon: Icons.gps_fixed, + title: 'GPS & Location', + subtitle: 'Fast updates, thresholds, and permissions', + children: [ + SwitchListTile( + dense: true, + secondary: const Icon(Icons.gps_fixed, size: 20), + title: Text(l10n.fastPrivateGpsUpdates), + subtitle: const Text( + 'Zero-hop updates while moving or actively using app', + ), + value: _fastLocationUpdatesEnabled, + onChanged: _setFastLocationUpdatesEnabled, + ), + ListTile( + dense: true, + leading: const Icon(Icons.straighten, size: 20), + title: Text(l10n.movementThreshold), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m', + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: _editFastLocationMovementThreshold, + ), + ListTile( + dense: true, + leading: const Icon(Icons.timer, size: 20), + title: Text(l10n.activeuseUpdateInterval), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_fastLocationActiveCadenceSeconds}s', + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: _editFastLocationActiveCadence, + ), + ListTile( + dense: true, + leading: const Icon(Icons.forum, size: 20), + title: const Text('Fast GPS target channel'), + subtitle: Text( + _describeFastLocationChannel( + context.watch().channels, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: _editFastLocationChannel, + ), + ListTile( + dense: true, + leading: const Icon(Icons.send, size: 20), + title: const Text('Test send update'), + trailing: const Icon(Icons.chevron_right), + onTap: _sendTestFastLocationUpdate, + ), + const Divider(height: 1), + ListTile( + dense: true, + leading: const Icon(Icons.location_on, size: 20), + title: Text(l10n.locationPermission), + subtitle: FutureBuilder( + future: Geolocator.checkPermission(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Text(l10n.checking); + } + final permission = snapshot.data!; + String statusText; + Color statusColor; + + switch (permission) { + case LocationPermission.always: + statusText = l10n.locationPermissionGrantedAlways; + statusColor = Colors.green; + break; + case LocationPermission.whileInUse: + statusText = l10n.locationPermissionGrantedWhileInUse; + statusColor = Colors.green; + break; + case LocationPermission.denied: + statusText = l10n.locationPermissionDeniedTapToRequest; + statusColor = Colors.orange; + break; + case LocationPermission.deniedForever: + statusText = + l10n.locationPermissionPermanentlyDeniedOpenSettings; + statusColor = Colors.red; + break; + default: + statusText = l10n.unknown; + statusColor = Colors.grey; + } + + return Text(statusText, style: TextStyle(color: statusColor)); + }, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _handleLocationPermissionTap(), + ), + ]), + const SizedBox(height: 12), + + // ── Voice ── Consumer2( builder: (context, appProvider, connectionProvider, child) => _buildVoiceStatsCard( @@ -1758,21 +1829,32 @@ class _SettingsScreenState extends State { silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, ), ), - _buildSettingsCard([ + _buildSection( + icon: Icons.mic_rounded, + title: 'Voice', + subtitle: 'Codec, bitrate, and audio processing', + children: [ ListTile( - leading: Icon(Icons.graphic_eq), - title: Text(AppLocalizations.of(context)!.voiceBitrate), - subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), - trailing: const Icon(Icons.chevron_right), + dense: true, + leading: const Icon(Icons.graphic_eq, size: 20), + title: Text(l10n.voiceBitrate), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _voiceBitrateSubtitle(_voiceBitrate), + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), onTap: _showVoiceBitrateDialog, ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.tune), - title: Text(AppLocalizations.of(context)!.bandpassFilterVoice), - subtitle: const Text( - 'Keeps speech frequencies and cuts low/high noise', - ), + dense: true, + secondary: const Icon(Icons.tune, size: 20), + title: Text(l10n.bandpassFilterVoice), value: appProvider.isVoiceBandPassFilterEnabled, onChanged: (value) async { await appProvider.toggleVoiceBandPassFilterEnabled(value); @@ -1781,13 +1863,9 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.compress), - title: Text(AppLocalizations.of(context)!.voiceCompressor), - subtitle: Text( - AppLocalizations.of( - context, - )!.balancesQuietAndLoudSpeechLevels, - ), + dense: true, + secondary: const Icon(Icons.compress, size: 20), + title: Text(l10n.voiceCompressor), value: appProvider.isVoiceCompressorEnabled, onChanged: (value) async { await appProvider.toggleVoiceCompressorEnabled(value); @@ -1796,13 +1874,9 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.speed), - title: Text(AppLocalizations.of(context)!.voiceLimiter), - subtitle: Text( - AppLocalizations.of( - context, - )!.preventsClippingPeaksBeforeEncoding, - ), + dense: true, + secondary: const Icon(Icons.speed, size: 20), + title: Text(l10n.voiceLimiter), value: appProvider.isVoiceLimiterEnabled, onChanged: (value) async { await appProvider.toggleVoiceLimiterEnabled(value); @@ -1811,11 +1885,9 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.auto_fix_high), - title: Text(AppLocalizations.of(context)!.micAutoGain), - subtitle: Text( - AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel, - ), + dense: true, + secondary: const Icon(Icons.auto_fix_high, size: 20), + title: Text(l10n.micAutoGain), value: appProvider.isVoiceAutoGainEnabled, onChanged: (value) async { await appProvider.toggleVoiceAutoGainEnabled(value); @@ -1824,11 +1896,9 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.hearing_disabled), - title: Text(AppLocalizations.of(context)!.echoCancellation), - subtitle: const Text( - 'Uses recorder echo cancellation if available', - ), + dense: true, + secondary: const Icon(Icons.hearing_disabled, size: 20), + title: Text(l10n.echoCancellation), value: appProvider.isVoiceEchoCancellationEnabled, onChanged: (value) async { await appProvider.toggleVoiceEchoCancellationEnabled(value); @@ -1837,11 +1907,9 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.noise_control_off), - title: Text(AppLocalizations.of(context)!.noiseSuppression), - subtitle: const Text( - 'Uses recorder noise suppression if available', - ), + dense: true, + secondary: const Icon(Icons.noise_control_off, size: 20), + title: Text(l10n.noiseSuppression), value: appProvider.isVoiceNoiseSuppressionEnabled, onChanged: (value) async { await appProvider.toggleVoiceNoiseSuppressionEnabled(value); @@ -1850,13 +1918,9 @@ class _SettingsScreenState extends State { ), Consumer( builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.content_cut), - title: Text( - AppLocalizations.of(context)!.trimSilenceInVoiceMessages, - ), - subtitle: const Text( - 'Removes long silent parts before sending voice', - ), + dense: true, + secondary: const Icon(Icons.content_cut, size: 20), + title: Text(l10n.trimSilenceInVoiceMessages), value: appProvider.isVoiceSilenceTrimmingEnabled, onChanged: (value) async { await appProvider.toggleVoiceSilenceTrimmingEnabled(value); @@ -1864,26 +1928,35 @@ class _SettingsScreenState extends State { ), ), ]), + const SizedBox(height: 12), - _buildSectionHeader('Images'), - _buildSettingsCard([ + // ── Images ── + _buildSection( + icon: Icons.image_rounded, + title: 'Images', + subtitle: 'Size, compression, and preview', + children: [ ListTile( - leading: Icon(Icons.photo_size_select_large), - title: Text(AppLocalizations.of(context)!.maxImageSize), - subtitle: Text('$_imageMaxSize×$_imageMaxSize px'), - trailing: const Icon(Icons.chevron_right), + dense: true, + leading: const Icon(Icons.photo_size_select_large, size: 20), + title: Text(l10n.maxImageSize), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$_imageMaxSize×$_imageMaxSize px', + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), onTap: _showImageMaxSizeDialog, ), ListTile( - leading: Icon(Icons.tune), - title: Text(AppLocalizations.of(context)!.imageCompression), - subtitle: Text( - '$_imageCompression / 90 (higher = smaller file)', - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Slider( + dense: true, + leading: const Icon(Icons.tune, size: 20), + title: Text(l10n.imageCompression), + subtitle: Slider( value: _imageCompression.toDouble(), min: 10, max: 90, @@ -1894,11 +1967,10 @@ class _SettingsScreenState extends State { ), ), SwitchListTile( - secondary: Icon(Icons.invert_colors), - title: Text(AppLocalizations.of(context)!.grayscale), - subtitle: const Text( - 'Converts image to grayscale for smaller file size', - ), + dense: true, + secondary: const Icon(Icons.invert_colors, size: 20), + title: Text(l10n.grayscale), + subtitle: const Text('Smaller file size'), value: _imageGrayscale, onChanged: (value) async { await ImagePreferences.setGrayscale(value); @@ -1907,17 +1979,14 @@ class _SettingsScreenState extends State { }, ), SwitchListTile( - secondary: Icon(Icons.compress), - title: Text(AppLocalizations.of(context)!.ultraMode), - subtitle: const Text( - 'Extra-aggressive compression with stronger AVIF settings', - ), + dense: true, + secondary: const Icon(Icons.compress, size: 20), + title: Text(l10n.ultraMode), + subtitle: const Text('Extra-aggressive AVIF compression'), value: _imageUltraMode, onChanged: (value) async { await ImagePreferences.setUltraMode(value); - setState(() { - _imageUltraMode = value; - }); + setState(() => _imageUltraMode = value); await _refreshImageModePreview(); }, ), @@ -1926,29 +1995,32 @@ class _SettingsScreenState extends State { builder: (context, connectionProvider, child) => _buildImageModePreviewCard(connectionProvider), ), - _buildSectionHeader('Profiles'), - _buildSettingsCard([ + const SizedBox(height: 12), + + // ── Profiles ── + _buildSection( + icon: Icons.layers_outlined, + title: 'Profiles', + subtitle: 'Multi-device workspace management', + children: [ SwitchListTile( - secondary: Icon(Icons.layers_outlined), - title: Text(AppLocalizations.of(context)!.enableProfiles), - subtitle: const Text( - 'Show profile management UI while keeping the hidden Default profile as the current workspace.', - ), + dense: true, + secondary: const Icon(Icons.layers_outlined, size: 20), + title: Text(l10n.enableProfiles), value: _profilesEnabled, onChanged: (value) async { await context .read() .setProfilesEnabled(value); if (!mounted) return; - setState(() { - _profilesEnabled = value; - }); + setState(() => _profilesEnabled = value); }, ), if (_profilesEnabled) ListTile( - leading: Icon(Icons.folder_copy_outlined), - title: Text(AppLocalizations.of(context)!.manageProfiles), + dense: true, + leading: const Icon(Icons.folder_copy_outlined, size: 20), + title: Text(l10n.manageProfiles), subtitle: Text( context.watch().activeProfileId == ConfigProfile.defaultProfileId @@ -1966,12 +2038,18 @@ class _SettingsScreenState extends State { }, ), ]), - _buildSectionHeader('Templates & Help'), - _buildSettingsCard([ + const SizedBox(height: 12), + + // ── Help ── + _buildSection( + icon: Icons.help_outline_rounded, + title: 'Help', + subtitle: 'Templates and tutorials', + children: [ ListTile( - leading: Icon(Icons.location_searching), - title: Text(AppLocalizations.of(context)!.sarTemplates), - subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates), + dense: true, + leading: const Icon(Icons.location_searching, size: 20), + title: Text(l10n.sarTemplates), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -1983,8 +2061,9 @@ class _SettingsScreenState extends State { }, ), ListTile( - leading: Icon(Icons.school), - title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial), + dense: true, + leading: const Icon(Icons.school, size: 20), + title: Text(l10n.viewWelcomeTutorial), trailing: const Icon(Icons.chevron_right), onTap: () async { await Navigator.push( @@ -2000,112 +2079,17 @@ class _SettingsScreenState extends State { }, ), ]), + const SizedBox(height: 12), - _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), - _buildSettingsCard([ - SwitchListTile( - secondary: Icon(Icons.gps_fixed), - title: Text(AppLocalizations.of(context)!.fastPrivateGpsUpdates), - subtitle: const Text( - 'Use private zero-hop updates while moving significantly or while actively using map/messages.', - ), - value: _fastLocationUpdatesEnabled, - onChanged: _setFastLocationUpdatesEnabled, - ), + // ── About ── + _buildSection( + icon: Icons.info_outline_rounded, + title: l10n.about, + children: [ ListTile( - leading: Icon(Icons.straighten), - title: Text(AppLocalizations.of(context)!.movementThreshold), - subtitle: Text( - '${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m', - ), - trailing: const Icon(Icons.chevron_right), - onTap: _editFastLocationMovementThreshold, - ), - ListTile( - leading: Icon(Icons.timer), - title: Text( - AppLocalizations.of(context)!.activeuseUpdateInterval, - ), - subtitle: Text('$_fastLocationActiveCadenceSeconds s'), - trailing: const Icon(Icons.chevron_right), - onTap: _editFastLocationActiveCadence, - ), - ListTile( - leading: const Icon(Icons.forum), - title: const Text('Fast GPS target channel'), - subtitle: Text( - _describeFastLocationChannel( - context.watch().channels, - ), - ), - trailing: const Icon(Icons.chevron_right), - onTap: _editFastLocationChannel, - ), - ListTile( - leading: const Icon(Icons.send), - title: const Text('Test send update'), - subtitle: const Text( - 'Send one fast GPS update immediately to the configured channel.', - ), - trailing: const Icon(Icons.chevron_right), - onTap: _sendTestFastLocationUpdate, - ), - ListTile( - leading: Icon(Icons.location_on), - title: Text(AppLocalizations.of(context)!.locationPermission), - subtitle: FutureBuilder( - future: Geolocator.checkPermission(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Text(AppLocalizations.of(context)!.checking); - } - final permission = snapshot.data!; - String statusText; - Color statusColor; - - switch (permission) { - case LocationPermission.always: - statusText = AppLocalizations.of( - context, - )!.locationPermissionGrantedAlways; - statusColor = Colors.green; - break; - case LocationPermission.whileInUse: - statusText = AppLocalizations.of( - context, - )!.locationPermissionGrantedWhileInUse; - statusColor = Colors.green; - break; - case LocationPermission.denied: - statusText = AppLocalizations.of( - context, - )!.locationPermissionDeniedTapToRequest; - statusColor = Colors.orange; - break; - case LocationPermission.deniedForever: - statusText = AppLocalizations.of( - context, - )!.locationPermissionPermanentlyDeniedOpenSettings; - statusColor = Colors.red; - break; - default: - statusText = AppLocalizations.of(context)!.unknown; - statusColor = Colors.grey; - } - - return Text(statusText, style: TextStyle(color: statusColor)); - }, - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => _handleLocationPermissionTap(), - ), - ]), - - _buildSectionHeader(AppLocalizations.of(context)!.about), - _buildSettingsCard([ - ListTile( - leading: Icon(Icons.info), - title: Text(AppLocalizations.of(context)!.appVersion), + dense: true, + leading: const Icon(Icons.info_outline, size: 20), + title: Text(l10n.appVersion), subtitle: Text( _packageInfo != null ? '${_packageInfo!.version} (${_packageInfo!.buildNumber})' @@ -2114,44 +2098,36 @@ class _SettingsScreenState extends State { onTap: _handleVersionTap, ), ListTile( - leading: Icon(Icons.badge), - title: Text(AppLocalizations.of(context)!.appName), - subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'), - ), - ListTile( - leading: Icon(Icons.description), - title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar), - subtitle: Text( - AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0], - ), + dense: true, + leading: const Icon(Icons.description_outlined, size: 20), + title: Text(l10n.aboutMeshCoreSar), + trailing: const Icon(Icons.chevron_right), onTap: () => _showAboutDialog(), ), - ]), - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) - Padding( - padding: const EdgeInsets.only(top: 8), - child: FilledButton.icon( - onPressed: _isCheckingForUpdates ? null : _checkForUpdates, - icon: _isCheckingForUpdates + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + ListTile( + dense: true, + leading: _isCheckingForUpdates ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.system_update), - label: Text( + : const Icon(Icons.system_update, size: 20), + title: Text( _isCheckingForUpdates ? 'Checking...' : 'Check for Updates', ), - style: FilledButton.styleFrom( - minimumSize: const Size(double.infinity, 48), - ), + onTap: _isCheckingForUpdates ? null : _checkForUpdates, ), - ), - _buildSectionHeader('Developer & Data'), - _buildSettingsCard([ + ]), + const SizedBox(height: 12), + + // ── Data & Developer ── + _buildSection( + icon: Icons.storage_rounded, + title: 'Data', + subtitle: 'Traffic stats, message history, and developer tools', + children: [ Consumer( builder: (context, appProvider, child) => ListenableBuilder( listenable: appProvider.trafficStatsReportingService, @@ -2161,95 +2137,161 @@ class _SettingsScreenState extends State { ), ), ListTile( - leading: Icon(Icons.bug_report), - title: Text(AppLocalizations.of(context)!.packageName), - subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'), - ), - Padding( - padding: EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - AppLocalizations.of(context)!.sampleData, - style: Theme.of(context).textTheme.titleSmall, + dense: true, + leading: const Icon(Icons.delete_sweep, size: 20), + title: const Text( + 'Clear Messages', + style: TextStyle(color: Colors.red), ), + subtitle: Text(l10n.deleteAllStoredMessageHistory), + onTap: _clearMessages, ), - Padding( - padding: EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - AppLocalizations.of(context)!.sampleDataDescription, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), + if (_isDeveloperModeEnabled) ...[ + const Divider(height: 1), + ListTile( + dense: true, + leading: const Icon(Icons.bug_report, size: 20), + title: Text(l10n.packageName), + subtitle: Text( + _packageInfo?.packageName ?? 'com.meshcore.sar', ), ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: _isLoadingSampleData ? null : _loadSampleData, - icon: _isLoadingSampleData - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Icon(Icons.add_circle_outline), - label: Text(AppLocalizations.of(context)!.loadSampleData), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: _isLoadingSampleData ? null : _clearSampleData, - icon: Icon(Icons.delete_outline), - label: Text(AppLocalizations.of(context)!.clearAllData), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + l10n.sampleData, + style: Theme.of(context).textTheme.labelMedium, + ), ), - ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Text( + l10n.sampleDataDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: + _isLoadingSampleData ? null : _loadSampleData, + icon: _isLoadingSampleData + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.add_circle_outline), + label: Text(l10n.loadSampleData), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: + _isLoadingSampleData ? null : _clearSampleData, + icon: const Icon(Icons.delete_outline), + label: Text(l10n.clearAllData), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + ], + ), + ), + ], ]), ], - ), - ); - } - - Widget _buildSectionHeader(String title) { - return Padding( - padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, ), ), ); } - Widget _buildSettingsCard(List children) { + Widget _buildSection({ + required IconData icon, + required String title, + String? subtitle, + required List children, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Card( - margin: EdgeInsets.zero, - child: Column( - children: ListTile.divideTiles( - context: context, - tiles: children, - color: Theme.of(context).dividerColor.withValues(alpha: 0.4), - ).toList(), + color: colorScheme.surfaceContainerLow, + elevation: 0, + surfaceTintColor: Colors.transparent, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: colorScheme.primary, size: 22), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + if (subtitle != null) + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + ...children, + ], + ), ), ); } + Widget _buildCompactToggleRow({ + required IconData icon, + required String label, + required bool value, + required ValueChanged onChanged, + }) { + return SwitchListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + secondary: Icon(icon, size: 18), + title: Text(label), + value: value, + onChanged: onChanged, + ); + } + Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) { final sourceBytes = _previewSourceBytes; final fileName = _previewSourceName ?? 'No image selected'; @@ -2284,7 +2326,7 @@ class _SettingsScreenState extends State { ); return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: const EdgeInsets.only(top: 4), child: Padding( padding: const EdgeInsets.all(12), child: Column( @@ -2292,7 +2334,7 @@ class _SettingsScreenState extends State { children: [ const Row( children: [ - Icon(Icons.photo_library_outlined), + Icon(Icons.photo_library_outlined, size: 20), SizedBox(width: 8), Text( 'Image mode preview', @@ -2482,7 +2524,7 @@ class _SettingsScreenState extends State { ); return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + margin: const EdgeInsets.only(bottom: 4), child: Padding( padding: const EdgeInsets.all(12), child: Column( diff --git a/lib/widgets/settings/traffic_stats_reporting_section.dart b/lib/widgets/settings/traffic_stats_reporting_section.dart index 7be3455..f5336ea 100644 --- a/lib/widgets/settings/traffic_stats_reporting_section.dart +++ b/lib/widgets/settings/traffic_stats_reporting_section.dart @@ -11,62 +11,47 @@ class TrafficStatsReportingSection extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( - secondary: const Icon(Icons.cloud_upload_outlined), - title: const Text('Anonymous RX stats reporting'), + dense: true, + secondary: const Icon(Icons.cloud_upload_outlined, size: 20), + title: const Text('Anonymous RX stats'), subtitle: const Text( - 'Upload RX live-traffic packet type and path mode totals to the fixed Cloudflare worker every 5 minutes.', + 'Upload packet totals every 5 min', ), value: service.isEnabled, onChanged: (value) async { await service.setEnabled(value); }, ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Upload status', - style: theme.textTheme.titleSmall, - ), - const SizedBox(height: 8), - Text( + if (service.isEnabled) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Row( + children: [ + Expanded( + child: Text( _statusText(service), - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 6), - Text( - 'Ingest URL: ${TrafficStatsReportingService.ingestUri}', style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 8), - TextButton.icon( - onPressed: _openStatsDashboard, - icon: const Icon(Icons.open_in_new), - label: const Text('View public stats'), + ), + TextButton.icon( + onPressed: _openStatsDashboard, + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('View'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + textStyle: theme.textTheme.labelSmall, ), - ], - ), + ), + ], ), ), - ), ], ); } @@ -79,21 +64,15 @@ class TrafficStatsReportingSection extends StatelessWidget { } static String _statusText(TrafficStatsReportingService service) { - final buffer = StringBuffer(); - buffer.write('Pending uploads: ${service.pendingUploadCount}'); + final parts = []; + parts.add('Pending: ${service.pendingUploadCount}'); if (service.lastSuccessAt != null) { - buffer.write( - '\nLast sent: ${_formatDateTime(service.lastSuccessAt!.toLocal())}', - ); - } else { - buffer.write('\nLast sent: Never'); + parts.add('Sent: ${_formatDateTime(service.lastSuccessAt!.toLocal())}'); } if (service.lastError != null && service.lastError!.isNotEmpty) { - buffer.write('\nLast error: ${service.lastError}'); - } else { - buffer.write('\nLast error: None'); + parts.add('Error: ${service.lastError}'); } - return buffer.toString(); + return parts.join(' · '); } static String _formatDateTime(DateTime value) { @@ -101,7 +80,6 @@ class TrafficStatsReportingSection extends StatelessWidget { final day = value.day.toString().padLeft(2, '0'); final hour = value.hour.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0'); - final second = value.second.toString().padLeft(2, '0'); - return '${value.year}-$month-$day $hour:$minute:$second'; + return '${value.year}-$month-$day $hour:$minute'; } }