import 'dart:typed_data'; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_avif/flutter_avif.dart'; import 'package:image_picker/image_picker.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:url_launcher/url_launcher.dart'; import '../providers/contacts_provider.dart'; import '../providers/messages_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; import '../providers/drawing_provider.dart'; import '../providers/map_provider.dart'; import '../models/contact.dart'; import '../models/config_profile.dart'; import '../services/location_tracking_service.dart'; import '../services/locale_preferences.dart'; import '../services/mesh_map_nodes_service.dart'; import '../services/update_checker_service.dart'; import '../services/voice_bitrate_preferences.dart'; import '../services/image_preferences.dart'; import '../services/route_hash_preferences.dart'; import '../services/message_destination_preferences.dart'; import '../services/image_codec_service.dart'; import '../services/developer_mode_service.dart'; import '../services/notification_service.dart'; import '../services/profile_manager.dart'; import '../services/profile_workspace_coordinator.dart'; import '../services/profiles_feature_service.dart'; import '../utils/sample_data_generator.dart'; import '../utils/image_message_parser.dart'; import '../utils/voice_message_parser.dart'; import '../theme/app_theme.dart'; import '../l10n/app_localizations.dart'; import '../widgets/update_dialog.dart'; import '../widgets/settings/traffic_stats_reporting_section.dart'; import 'sar_template_management_screen.dart'; import 'profiles_screen.dart'; import 'welcome_wizard_screen.dart'; class SettingsScreen extends StatefulWidget { final Function(AppThemeMode) onThemeChanged; final Function(Locale?) onLocaleChanged; final AppThemeMode currentTheme; final Locale? currentLocale; const SettingsScreen({ super.key, required this.onThemeChanged, required this.onLocaleChanged, required this.currentTheme, required this.currentLocale, }); @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { static const String _publicChannelPublicKeyHex = '0000000000000000000000000000000000000000000000000000000000000000'; late AppThemeMode _selectedTheme; late Locale? _selectedLocale; PackageInfo? _packageInfo; bool _isLoadingSampleData = false; bool _showRxTxIndicators = true; bool _isCheckingForUpdates = false; int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; int _routeHashSize = RouteHashPreferences.defaultHashSize; int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageCompression = ImagePreferences.defaultQuality; bool _imageGrayscale = ImagePreferences.defaultGrayscale; bool _imageUltraMode = ImagePreferences.defaultUltraMode; Uint8List? _previewSourceBytes; String? _previewSourceName; Uint8List? _previewCompressedBytes; bool _isPreviewLoading = false; bool _showCurrentImagePreview = true; bool _fastLocationUpdatesEnabled = false; double _fastLocationMovementThresholdMeters = 10.0; int _fastLocationActiveCadenceSeconds = 10; int? _fastLocationChannelIdx; bool _rotateMapWithHeading = false; bool _showMapDebugInfo = false; bool _openMapInFullscreen = false; bool _messageNotificationsEnabled = true; bool _sarNotificationsEnabled = true; bool _discoveryNotificationsEnabled = true; bool _updateNotificationsEnabled = true; bool _muteForegroundNotifications = true; bool _isDeveloperModeEnabled = false; bool _profilesEnabled = false; bool _messageDestinationLockEnabled = false; String _messageDestinationLockType = MessageDestinationPreferences.destinationTypeChannel; String? _messageDestinationLockPublicKey = _publicChannelPublicKeyHex; DateTime? _onlineTraceCacheUpdatedAt; bool _isClearingOnlineTraceCache = false; int _versionTapCount = 0; final ImagePicker _imagePicker = ImagePicker(); final LocationTrackingService _locationService = LocationTrackingService(); @override void initState() { super.initState(); _selectedTheme = widget.currentTheme; _selectedLocale = widget.currentLocale; _loadPackageInfo(); _initializeLocationService(); _loadRxTxPreference(); _loadVoicePreferences(); _loadRouteHashSizePreference(); _loadImagePreferences(); _loadFastLocationSettings(); _loadDeveloperMode(); _loadProfilesEnabled(); _loadOnlineTraceCacheStatus(); _loadMapPreferences(); _loadNotificationPreferences(); _loadMessageDestinationLock(); } @override void dispose() { // Clear location service callbacks to prevent memory leaks _locationService.onError = null; _locationService.onBroadcastSent = null; _locationService.onTrackingStateChanged = null; super.dispose(); } Future _loadPackageInfo() async { final info = await PackageInfo.fromPlatform(); if (mounted) { setState(() { _packageInfo = info; }); } } Future _loadRxTxPreference() async { final prefs = await SharedPreferences.getInstance(); if (mounted) { setState(() { _showRxTxIndicators = prefs.getBool( ProfileStorageScope.scopedKey('show_rx_tx_indicators'), ) ?? true; }); } } Future _loadDeveloperMode() async { final isEnabled = await DeveloperModeService.isEnabled(); if (!mounted) return; setState(() { _isDeveloperModeEnabled = isEnabled; }); } Future _loadProfilesEnabled() async { final isEnabled = await ProfilesFeatureService.isEnabled(); if (!mounted) return; setState(() { _profilesEnabled = isEnabled; }); } Future _loadNotificationPreferences() async { final service = NotificationService(); await service.initialize(); if (!mounted) return; setState(() { _messageNotificationsEnabled = service.messageNotificationsEnabled; _sarNotificationsEnabled = service.sarNotificationsEnabled; _discoveryNotificationsEnabled = service.discoveryNotificationsEnabled; _updateNotificationsEnabled = service.updateNotificationsEnabled; _muteForegroundNotifications = service.muteForegroundNotifications; }); } Future _loadOnlineTraceCacheStatus() async { final cachedAt = await MeshMapNodesService.cachedAt(); if (!mounted) return; setState(() { _onlineTraceCacheUpdatedAt = cachedAt; }); } Future _loadMessageDestinationLock() async { final lockedDestination = await MessageDestinationPreferences.getLockedDestination(); if (!mounted) return; setState(() { _messageDestinationLockEnabled = lockedDestination != null; _messageDestinationLockType = lockedDestination?['publicKey'] == null ? MessageDestinationPreferences.destinationTypeChannel : lockedDestination?['type'] ?? MessageDestinationPreferences.destinationTypeChannel; _messageDestinationLockPublicKey = lockedDestination?['publicKey'] ?? _publicChannelPublicKeyHex; }); } List _messageDestinationLockOptions( ContactsProvider contactsProvider, ) { final channels = List.from(contactsProvider.channels) ..sort((a, b) { if (a.isPublicChannel != b.isPublicChannel) { return a.isPublicChannel ? -1 : 1; } return a.displayName.toLowerCase().compareTo( b.displayName.toLowerCase(), ); }); final rooms = List.from(contactsProvider.rooms) ..sort( (a, b) => a.displayName.toLowerCase().compareTo( b.displayName.toLowerCase(), ), ); return [...channels, ...rooms]; } String _messageDestinationLockLabel(BuildContext context, Contact contact) { final name = contact.isChannel ? contact.getLocalizedDisplayName(context) : contact.displayName; return contact.isRoom ? 'Room: $name' : 'Channel: $name'; } String _messageDestinationLockTypeForContact(Contact contact) { return contact.isRoom ? MessageDestinationPreferences.destinationTypeRoom : MessageDestinationPreferences.destinationTypeChannel; } String? _selectedMessageDestinationLockValue(List destinations) { final currentValue = _messageDestinationLockPublicKey; if (currentValue != null && destinations.any((contact) => contact.publicKeyHex == currentValue)) { return currentValue; } return destinations.isEmpty ? null : destinations.first.publicKeyHex; } Future _setMessageDestinationLock({ required bool enabled, String? type, String? recipientPublicKey, }) async { final nextType = type ?? _messageDestinationLockType; final nextRecipientPublicKey = recipientPublicKey ?? _messageDestinationLockPublicKey ?? _publicChannelPublicKeyHex; await MessageDestinationPreferences.setLockedDestination( enabled: enabled, type: nextType, recipientPublicKey: enabled ? nextRecipientPublicKey : null, ); if (!mounted) return; setState(() { _messageDestinationLockEnabled = enabled; _messageDestinationLockType = nextType; _messageDestinationLockPublicKey = nextRecipientPublicKey; }); } Future _handleVersionTap() async { if (_isDeveloperModeEnabled) { await DeveloperModeService.setEnabled(false); if (!mounted) return; setState(() { _isDeveloperModeEnabled = false; _versionTapCount = 0; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.developerModeDisabled), ), ); return; } final nextTapCount = _versionTapCount + 1; if (nextTapCount >= 3) { await DeveloperModeService.setEnabled(true); if (!mounted) return; setState(() { _isDeveloperModeEnabled = true; _versionTapCount = 0; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.developerModeEnabled), ), ); return; } if (!mounted) return; setState(() { _versionTapCount = nextTapCount; }); } Future _saveRxTxPreference(bool value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool( ProfileStorageScope.scopedKey('show_rx_tx_indicators'), value, ); } Future _loadMapPreferences() async { final prefs = await SharedPreferences.getInstance(); if (!mounted) return; setState(() { _rotateMapWithHeading = prefs.getBool( ProfileStorageScope.scopedKey('map_rotate_with_heading'), ) ?? false; _showMapDebugInfo = prefs.getBool(ProfileStorageScope.scopedKey('map_show_debug_info')) ?? false; _openMapInFullscreen = prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ?? false; }); } Future _saveMapPreference(String key, bool value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(ProfileStorageScope.scopedKey(key), value); } Future _loadVoicePreferences() async { final value = await VoiceBitratePreferences.getBitrate(); if (!mounted) return; setState(() { _voiceBitrate = value; }); } Future _saveVoiceBitratePreference(int value) async { await VoiceBitratePreferences.setBitrate(value); if (!mounted) return; setState(() { _voiceBitrate = value; }); } String _voiceBitrateSubtitle(int bitrate) { return '$bitrate bps'; } Future _loadRouteHashSizePreference() async { final value = await RouteHashPreferences.getHashSize(); if (!mounted) return; setState(() { _routeHashSize = value; }); } Future _saveRouteHashSizePreference(int value) async { await RouteHashPreferences.setHashSize(value); if (!mounted) return; setState(() { _routeHashSize = value; }); } Future _loadImagePreferences() async { final size = await ImagePreferences.getMaxSize(); final compression = await ImagePreferences.getCompression(); final grayscale = await ImagePreferences.getGrayscale(); final ultraMode = await ImagePreferences.getUltraMode(); if (!mounted) return; setState(() { _imageMaxSize = ImagePreferences.effectiveMaxSize( size, ultraMode: ultraMode, ); _imageCompression = compression; _imageGrayscale = grayscale; _imageUltraMode = ultraMode; }); await _refreshImageModePreview(); } Future _loadFastLocationSettings() async { await _locationService.loadSettings(); if (!mounted) return; setState(() { _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; _fastLocationMovementThresholdMeters = _locationService.fastLocationMovementThresholdMeters; _fastLocationActiveCadenceSeconds = _locationService.fastLocationActiveCadenceSeconds; _fastLocationChannelIdx = _locationService.fastLocationChannelIdx; }); } Future _setFastLocationUpdatesEnabled(bool enabled) async { await _locationService.setFastLocationUpdatesEnabled(enabled); if (!mounted) return; context.read().notifyChannelLocationSharingChanged(); setState(() { _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; }); } Future _editFastLocationMovementThreshold() async { final controller = TextEditingController( text: _fastLocationMovementThresholdMeters.toStringAsFixed(0), ); final value = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.fastGpsMovementThreshold), content: TextField( controller: controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Meters', helperText: 'Valid range: 10 to 1000 meters', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.cancel), ), FilledButton( onPressed: () { final parsed = double.tryParse(controller.text.trim()); if (parsed == null) return; Navigator.pop(context, parsed.clamp(10.0, 1000.0)); }, child: const Text('Save'), ), ], ), ); if (value == null) return; await _locationService.updateFastLocationMovementThreshold(value); if (!mounted) return; setState(() { _fastLocationMovementThresholdMeters = _locationService.fastLocationMovementThresholdMeters; }); } Future _editFastLocationActiveCadence() async { final controller = TextEditingController( text: _fastLocationActiveCadenceSeconds.toString(), ); final value = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.fastGpsActiveuseInterval), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'Seconds', helperText: 'Valid range: 10 to 31 seconds', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.cancel), ), FilledButton( onPressed: () { final parsed = int.tryParse(controller.text.trim()); if (parsed == null) return; Navigator.pop(context, parsed.clamp(10, 31)); }, child: const Text('Save'), ), ], ), ); if (value == null) return; await _locationService.updateFastLocationActiveCadenceSeconds(value); if (!mounted) return; setState(() { _fastLocationActiveCadenceSeconds = _locationService.fastLocationActiveCadenceSeconds; }); } String _describeFastLocationChannel(List channels) { final channelIdx = _fastLocationChannelIdx; if (channelIdx == null) { return 'Not set'; } for (final channel in channels) { final idx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; if (idx == channelIdx) { return '${channel.getLocalizedDisplayName(context)} (slot $channelIdx)'; } } return 'Channel $channelIdx unavailable'; } Future _sendTestFastLocationUpdate() async { final appProvider = context.read(); final sent = await appProvider.sendTestFastLocationUpdate(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( sent ? 'Test fast GPS update sent.' : 'Unable to send test fast GPS update.', ), backgroundColor: sent ? Colors.green : Colors.orange, ), ); } Future _editFastLocationChannel() async { final channels = List.from(context.read().channels) ..removeWhere((c) => c.isPublicChannel) ..sort((a, b) { final aIdx = a.publicKey.length > 1 ? a.publicKey[1] : 0; final bIdx = b.publicKey.length > 1 ? b.publicKey[1] : 0; return aIdx.compareTo(bIdx); }); final selected = await showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.block), title: const Text('Disable fast GPS publishing'), trailing: _fastLocationChannelIdx == null ? const Icon(Icons.check) : null, onTap: () => Navigator.pop(sheetContext, -1), ), for (final channel in channels) ListTile( leading: const Icon(Icons.tag), title: Text(channel.getLocalizedDisplayName(sheetContext)), subtitle: Text( 'Channel ${channel.publicKey.length > 1 ? channel.publicKey[1] : 0}', ), trailing: _fastLocationChannelIdx == (channel.publicKey.length > 1 ? channel.publicKey[1] : 0) ? const Icon(Icons.check) : null, onTap: () => Navigator.pop( sheetContext, channel.publicKey.length > 1 ? channel.publicKey[1] : 0, ), ), ], ), ), ); if (selected == null) return; await _locationService.updateFastLocationChannelIdx( selected < 0 ? null : selected, ); if (!mounted) return; context.read().notifyChannelLocationSharingChanged(); setState(() { _fastLocationChannelIdx = _locationService.fastLocationChannelIdx; }); } Future _saveImageMaxSize(int size) async { await ImagePreferences.setMaxSize(size); if (!mounted) return; setState(() => _imageMaxSize = size); await _refreshImageModePreview(); } Future _saveImageCompression(int compression) async { await ImagePreferences.setCompression(compression); if (!mounted) return; setState(() => _imageCompression = compression); await _refreshImageModePreview(); } Future _refreshImageModePreview() async { final sourceBytes = _previewSourceBytes; if (sourceBytes == null) { if (!mounted) return; setState(() => _previewCompressedBytes = null); return; } setState(() => _isPreviewLoading = true); try { final result = await ImageCodecService.compress( sourceBytes, maxDimension: ImagePreferences.effectiveMaxSize( _imageMaxSize, ultraMode: _imageUltraMode, ), compression: _imageCompression, grayscale: _imageGrayscale, ultraMode: _imageUltraMode, ); if (!mounted) return; setState(() { _previewCompressedBytes = result?.bytes; _isPreviewLoading = false; }); } catch (_) { if (!mounted) return; setState(() { _previewCompressedBytes = null; _isPreviewLoading = false; }); } } Future _selectPreviewImageFromGallery() async { try { final picked = await _imagePicker.pickImage(source: ImageSource.gallery); if (picked == null) return; final bytes = await picked.readAsBytes(); if (!mounted) return; setState(() { _previewSourceBytes = bytes; _previewSourceName = picked.name; }); await _refreshImageModePreview(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to load preview image: $e'), backgroundColor: Colors.red, ), ); } } Future _initializeLocationService() async { // Initialize location service with BLE service WidgetsBinding.instance.addPostFrameCallback((_) async { if (mounted) { final appProvider = context.read(); await _locationService.initialize( appProvider.connectionProvider.bleService, ); // Set up callbacks for UI feedback _locationService.onError = (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(error), backgroundColor: Colors.orange), ); } }; _locationService.onBroadcastSent = (position) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationBroadcast( position.latitude.toStringAsFixed(5), position.longitude.toStringAsFixed(5), ), ), backgroundColor: Colors.green, duration: const Duration(seconds: 2), ), ); } }; _locationService.onTrackingStateChanged = (isTracking) { if (mounted) { setState(() {}); } }; // Load settings and restore tracking state final prefs = await SharedPreferences.getInstance(); final wasTracking = prefs.getBool( ProfileStorageScope.scopedKey('background_tracking_enabled'), ) ?? false; if (wasTracking) { await _startBackgroundTracking(); } if (mounted) { setState(() {}); } } }); } Future _saveThemePreference(AppThemeMode theme) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString('theme_mode', theme.name); } void _handleThemeChange(AppThemeMode? theme) { if (theme != null) { setState(() { _selectedTheme = theme; }); _saveThemePreference(theme); widget.onThemeChanged(theme); } } Future _saveLocalePreference(Locale? locale) async { await LocalePreferences.setLocale(locale); } /// Check for app updates and show notification or dialog Future _checkForUpdates() async { // Only on Android if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid, ), backgroundColor: Colors.orange, ), ); } return; } setState(() { _isCheckingForUpdates = true; }); try { debugPrint('[Settings] Checking for updates...'); final updateInfo = await UpdateCheckerService().checkForUpdate(); if (!mounted) return; setState(() { _isCheckingForUpdates = false; }); if (!updateInfo.isAvailable) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.youAreRunningTheLatestVersion, ), backgroundColor: Colors.green, ), ); return; } if (updateInfo.downloadUrl == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of( context, )!.updateAvailableButDownloadUrlNotFound, ), backgroundColor: Colors.orange, ), ); return; } // Show dialog with update details UpdateDialog.show(context, updateInfo); } catch (e) { debugPrint('[Settings] Error checking for updates: $e'); if (mounted) { setState(() { _isCheckingForUpdates = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Error checking for updates: $e'), backgroundColor: Colors.red, ), ); } } } void _handleLocaleChange(Locale? locale) { setState(() { _selectedLocale = locale; }); _saveLocalePreference(locale); widget.onLocaleChanged(locale); } Future _loadSampleData() async { setState(() => _isLoadingSampleData = true); try { // Get current location or use default LatLng centerLocation; try { final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.best, timeLimit: Duration(seconds: 5), ), ); centerLocation = LatLng(position.latitude, position.longitude); } catch (e) { // Default to Ljubljana, Slovenia if location unavailable centerLocation = const LatLng(46.0569, 14.5058); } if (!mounted) return; // Get localization final l10n = AppLocalizations.of(context)!; // Generate sample data final contacts = SampleDataGenerator.generateContacts( centerLocation: centerLocation, l10n: l10n, teamMemberCount: 5, channelCount: 2, ); final sampleMessages = SampleDataGenerator.generateAllMessages( centerLocation: centerLocation, l10n: l10n, foundPersonCount: 2, fireCount: 1, stagingCount: 1, objectCount: 1, generalChannelMessages: 8, emergencyChannelMessages: 5, ); // Add to providers final contactsProvider = Provider.of( context, listen: false, ); final messagesProvider = Provider.of( context, listen: false, ); contactsProvider.addContacts(contacts); for (final message in sampleMessages.messages) { messagesProvider.addMessage( message, contactLocationSnapshot: sampleMessages.contactLocations[message.id], ); } if (!mounted) return; final teamCount = contacts.where((c) => c.isChat).length; final channelCount = contacts.where((c) => c.isRoom).length; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.loadedSampleData( teamCount, channelCount, sampleMessages.messages.where((m) => m.isSarMarker).length, sampleMessages.messages.length, ), ), backgroundColor: Colors.green, ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.failedToLoadSampleData(e.toString()), ), backgroundColor: Colors.red, ), ); } finally { if (mounted) { setState(() => _isLoadingSampleData = false); } } } Future _handleLocationPermissionTap() async { try { final permission = await Geolocator.checkPermission(); if (permission == LocationPermission.deniedForever) { // Show dialog to open app settings if (!mounted) return; showDialog( context: context, builder: (context) => AlertDialog( title: Row( children: [ const Icon(Icons.settings, size: 24), SizedBox(width: 12), Text(AppLocalizations.of(context)!.locationPermission), ], ), content: Text( AppLocalizations.of(context)!.locationPermissionDialogContent, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.cancel), ), ElevatedButton( onPressed: () async { Navigator.pop(context); await Geolocator.openAppSettings(); }, child: Text(AppLocalizations.of(context)!.openSettings), ), ], ), ); } else if (permission == LocationPermission.denied) { // Request permission final newPermission = await Geolocator.requestPermission(); if (!mounted) return; if (newPermission == LocationPermission.whileInUse || newPermission == LocationPermission.always) { // Permission granted ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationPermissionGranted, ), backgroundColor: Colors.green, ), ); setState(() {}); // Refresh UI to show new status } else { // Permission denied ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationPermissionRequiredForGps, ), backgroundColor: Colors.orange, duration: const Duration(seconds: 4), ), ); } } else { // Already granted - show info if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationPermissionAlreadyGranted, ), backgroundColor: Colors.blue, ), ); } } catch (e) { debugPrint('Error handling location permission: $e'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), ); } } Future _startBackgroundTracking() async { final success = await _locationService.startTracking( distanceThreshold: _locationService.gpsUpdateDistance, ); if (!success && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.failedToStartBackgroundTracking, ), duration: const Duration(seconds: 3), ), ); } if (mounted) { setState(() {}); } } Future _clearSampleData() async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.clearAllDataConfirmTitle), content: Text(AppLocalizations.of(context)!.clearAllDataConfirmMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: Text(AppLocalizations.of(context)!.cancel), ), TextButton( onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: Text(AppLocalizations.of(context)!.clear), ), ], ), ); if (confirmed != true || !mounted) return; final contactsProvider = Provider.of( context, listen: false, ); final messagesProvider = Provider.of( context, listen: false, ); contactsProvider.clearContacts(); messagesProvider.clearAll(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.allDataCleared), backgroundColor: Colors.orange, ), ); } Future _clearMessages() async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.clearMessages), content: const Text( 'This will permanently delete all stored messages. Are you sure?', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: Text(AppLocalizations.of(context)!.cancel), ), TextButton( onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: Text(AppLocalizations.of(context)!.clear), ), ], ), ); if (confirmed != true || !mounted) return; final messagesProvider = Provider.of( context, listen: false, ); messagesProvider.clearMessages(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.allMessagesCleared), backgroundColor: Colors.orange, ), ); } Future _clearOnlineTraceCache() async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.clearOnlineTraceDatabase), content: const Text( 'This removes the cached online node database used as a trace fallback.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: Text(AppLocalizations.of(context)!.cancel), ), TextButton( onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: Text(AppLocalizations.of(context)!.clear), ), ], ), ); if (confirmed != true || !mounted) return; setState(() { _isClearingOnlineTraceCache = true; }); await MeshMapNodesService.clearCache(); if (!mounted) return; setState(() { _onlineTraceCacheUpdatedAt = null; _isClearingOnlineTraceCache = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.onlineTraceDatabaseCleared), backgroundColor: Colors.orange, ), ); } String _onlineTraceCacheSubtitle() { final cachedAt = _onlineTraceCacheUpdatedAt; if (cachedAt == null) { return 'No cached online database. Refresh runs in background when internet is available.'; } final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl); return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.'; } String _formatDateTime(DateTime value) { final local = value.toLocal(); String two(int part) => part.toString().padLeft(2, '0'); return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}'; } Future _showRouteHashSizeDialog() async { final selected = await showDialog( context: context, builder: (context) => SimpleDialog( title: Text(AppLocalizations.of(context)!.routePathByteSize), children: [ for (final value in RouteHashPreferences.supportedSizes) SimpleDialogOption( onPressed: () => Navigator.of(context).pop(value), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text('$value-byte (max ${64 ~/ value} hops)'), Text( 'Use ${value * 2} hex characters per hop', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), if (_routeHashSize == value) Icon( Icons.check, color: Theme.of(context).colorScheme.primary, ), ], ), ), ], ), ); if (selected == null) return; await _saveRouteHashSizePreference(selected); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), body: ListView( padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), children: [ _buildSectionHeader('Appearance'), _buildSettingsCard([ ListTile( leading: Icon(Icons.palette), title: Text(AppLocalizations.of(context)!.theme), subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)), trailing: 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), onTap: () => _showLanguageDialog(), ), SwitchListTile( secondary: Icon(Icons.radar), title: Text(AppLocalizations.of(context)!.showRxTxIndicators), subtitle: Text( AppLocalizations.of(context)!.displayPacketActivity, ), value: _showRxTxIndicators, onChanged: (value) async { 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', ), value: _messageNotificationsEnabled, onChanged: (value) async { 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', ), value: _sarNotificationsEnabled, onChanged: (value) async { 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', ), value: _discoveryNotificationsEnabled, onChanged: (value) async { 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', ), value: _updateNotificationsEnabled, onChanged: (value) async { 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', ), value: _muteForegroundNotifications, onChanged: (value) async { setState(() { _muteForegroundNotifications = value; }); await NotificationService().setMuteForegroundNotifications( value, ); }, ), ]), _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, ), 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', ), 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', ), 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', ), 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', ), value: appProvider.isContactsSectionEnabled( ContactsTabSection.teamMembers, ), onChanged: (value) async { await appProvider.setContactsSectionEnabled( ContactsTabSection.teamMembers, value, ); }, ), SwitchListTile( secondary: const Icon(Icons.router_outlined), title: Text(AppLocalizations.of(context)!.repeaters), subtitle: const Text('Show repeater nodes in the contacts tab'), value: appProvider.isContactsSectionEnabled( ContactsTabSection.repeaters, ), onChanged: (value) async { await appProvider.setContactsSectionEnabled( ContactsTabSection.repeaters, value, ); }, ), SwitchListTile( secondary: const Icon(Icons.sensors_outlined), title: Text(AppLocalizations.of(context)!.sensors), subtitle: const Text('Show sensor nodes in the contacts tab'), value: appProvider.isContactsSectionEnabled( ContactsTabSection.sensors, ), onChanged: (value) async { await appProvider.setContactsSectionEnabled( ContactsTabSection.sensors, value, ); }, ), SwitchListTile( secondary: const Icon(Icons.meeting_room_outlined), title: Text(AppLocalizations.of(context)!.rooms), subtitle: const Text('Show rooms in the contacts tab'), value: appProvider.isContactsSectionEnabled( ContactsTabSection.rooms, ), onChanged: (value) async { await appProvider.setContactsSectionEnabled( ContactsTabSection.rooms, value, ); }, ), SwitchListTile( secondary: const Icon(Icons.broadcast_on_personal_outlined), title: Text(AppLocalizations.of(context)!.channels), subtitle: const Text('Show channels in the contacts tab'), value: appProvider.isContactsSectionEnabled( ContactsTabSection.channels, ), onChanged: (value) async { await appProvider.setContactsSectionEnabled( ContactsTabSection.channels, value, ); }, ), ]), ), _buildSectionHeader('Messaging'), _buildSettingsCard([ ListTile( leading: Icon(Icons.alt_route), title: Text(AppLocalizations.of(context)!.routePathByteSize), subtitle: Text( '$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes', ), 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', ), value: appProvider.nearestRelayFallbackEnabled, onChanged: (value) async { await appProvider.toggleNearestRelayFallbackEnabled(value); }, ), ), 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', ), value: appProvider.clearPathOnMaxRetry, onChanged: (value) async { await appProvider.toggleClearPathOnMaxRetry(value); }, ), ), SwitchListTile( secondary: const Icon(Icons.lock_outline), title: const Text('Lock messages to one channel or room'), subtitle: const Text( 'Keep the Messages tab and composer fixed on one destination. Direct messages from Contacts still open as usual.', ), value: _messageDestinationLockEnabled, onChanged: (value) async { final contactsProvider = context.read(); final options = _messageDestinationLockOptions( contactsProvider, ); final selectedPublicKey = _selectedMessageDestinationLockValue(options) ?? _publicChannelPublicKeyHex; final selectedContact = options.where((contact) { return contact.publicKeyHex == selectedPublicKey; }).firstOrNull; await _setMessageDestinationLock( enabled: value, type: selectedContact == null ? MessageDestinationPreferences.destinationTypeChannel : _messageDestinationLockTypeForContact(selectedContact), recipientPublicKey: selectedPublicKey, ); }, ), 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, ), ), ], onChanged: _messageDestinationLockEnabled && 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, ); } : null, ), ); }, ), ListTile( leading: const Icon(Icons.delete_sweep, color: Colors.red), title: const Text( 'Clear Messages', style: TextStyle(color: Colors.red), ), 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); }, ), ), ), ), ]), _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([ SwitchListTile( secondary: Icon(Icons.explore), title: Text(AppLocalizations.of(context)!.rotateMapWithHeading), subtitle: const Text( 'Rotate the map based on your compass or movement heading', ), value: _rotateMapWithHeading, onChanged: (value) async { 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', ), value: _openMapInFullscreen, onChanged: (value) async { 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, ), value: drawingProvider.showSarMarkers, onChanged: (value) { drawingProvider.toggleSarMarkers(); }, ), ), 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', ), value: mapProvider.hideRepeatersOnMap, onChanged: (value) async { await mapProvider.setHideRepeatersOnMap(value); }, ), ), ]), _buildSectionHeader('Voice'), Consumer2( builder: (context, appProvider, connectionProvider, child) => _buildVoiceStatsCard( bitrate: _voiceBitrate, connectionProvider: connectionProvider, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, compressorEnabled: appProvider.isVoiceCompressorEnabled, limiterEnabled: appProvider.isVoiceLimiterEnabled, autoGainEnabled: appProvider.isVoiceAutoGainEnabled, echoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled, noiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, ), ), _buildSettingsCard([ ListTile( leading: Icon(Icons.graphic_eq), title: Text(AppLocalizations.of(context)!.voiceBitrate), subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), trailing: 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', ), value: appProvider.isVoiceBandPassFilterEnabled, onChanged: (value) async { await appProvider.toggleVoiceBandPassFilterEnabled(value); }, ), ), Consumer( builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.compress), title: Text(AppLocalizations.of(context)!.voiceCompressor), subtitle: Text( AppLocalizations.of( context, )!.balancesQuietAndLoudSpeechLevels, ), value: appProvider.isVoiceCompressorEnabled, onChanged: (value) async { await appProvider.toggleVoiceCompressorEnabled(value); }, ), ), Consumer( builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.speed), title: Text(AppLocalizations.of(context)!.voiceLimiter), subtitle: Text( AppLocalizations.of( context, )!.preventsClippingPeaksBeforeEncoding, ), value: appProvider.isVoiceLimiterEnabled, onChanged: (value) async { await appProvider.toggleVoiceLimiterEnabled(value); }, ), ), Consumer( builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.auto_fix_high), title: Text(AppLocalizations.of(context)!.micAutoGain), subtitle: Text( AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel, ), value: appProvider.isVoiceAutoGainEnabled, onChanged: (value) async { await appProvider.toggleVoiceAutoGainEnabled(value); }, ), ), 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', ), value: appProvider.isVoiceEchoCancellationEnabled, onChanged: (value) async { await appProvider.toggleVoiceEchoCancellationEnabled(value); }, ), ), 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', ), value: appProvider.isVoiceNoiseSuppressionEnabled, onChanged: (value) async { await appProvider.toggleVoiceNoiseSuppressionEnabled(value); }, ), ), 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', ), value: appProvider.isVoiceSilenceTrimmingEnabled, onChanged: (value) async { await appProvider.toggleVoiceSilenceTrimmingEnabled(value); }, ), ), ]), _buildSectionHeader('Images'), _buildSettingsCard([ 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), 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( value: _imageCompression.toDouble(), min: 10, max: 90, divisions: 8, label: '$_imageCompression', onChanged: (v) => setState(() => _imageCompression = v.round()), onChangeEnd: (v) => _saveImageCompression(v.round()), ), ), SwitchListTile( secondary: Icon(Icons.invert_colors), title: Text(AppLocalizations.of(context)!.grayscale), subtitle: const Text( 'Converts image to grayscale for smaller file size', ), value: _imageGrayscale, onChanged: (value) async { await ImagePreferences.setGrayscale(value); setState(() => _imageGrayscale = value); await _refreshImageModePreview(); }, ), SwitchListTile( secondary: Icon(Icons.compress), title: Text(AppLocalizations.of(context)!.ultraMode), subtitle: const Text( 'Extra-aggressive compression with stronger AVIF settings', ), value: _imageUltraMode, onChanged: (value) async { await ImagePreferences.setUltraMode(value); setState(() { _imageUltraMode = value; }); await _refreshImageModePreview(); }, ), ]), Consumer( builder: (context, connectionProvider, child) => _buildImageModePreviewCard(connectionProvider), ), _buildSectionHeader('Profiles'), _buildSettingsCard([ 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.', ), value: _profilesEnabled, onChanged: (value) async { await context .read() .setProfilesEnabled(value); if (!mounted) return; setState(() { _profilesEnabled = value; }); }, ), if (_profilesEnabled) ListTile( leading: Icon(Icons.folder_copy_outlined), title: Text(AppLocalizations.of(context)!.manageProfiles), subtitle: Text( context.watch().activeProfileId == ConfigProfile.defaultProfileId ? 'Default is active' : 'Custom profile active', ), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => const ProfilesScreen(), ), ); }, ), ]), _buildSectionHeader('Templates & Help'), _buildSettingsCard([ ListTile( leading: Icon(Icons.location_searching), title: Text(AppLocalizations.of(context)!.sarTemplates), subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => const SarTemplateManagementScreen(), ), ); }, ), ListTile( leading: Icon(Icons.school), title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial), trailing: const Icon(Icons.chevron_right), onTap: () async { await Navigator.push( context, MaterialPageRoute( builder: (context) => WelcomeWizardScreen( onCompleted: () { Navigator.of(context).pop(); }, ), ), ); }, ), ]), _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, ), 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), subtitle: Text( _packageInfo != null ? '${_packageInfo!.version} (${_packageInfo!.buildNumber})' : 'Loading...', ), 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], ), onTap: () => _showAboutDialog(), ), ]), if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) Padding( padding: const EdgeInsets.only(top: 8), child: FilledButton.icon( onPressed: _isCheckingForUpdates ? null : _checkForUpdates, icon: _isCheckingForUpdates ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.system_update), label: Text( _isCheckingForUpdates ? 'Checking...' : 'Check for Updates', ), style: FilledButton.styleFrom( minimumSize: const Size(double.infinity, 48), ), ), ), _buildSectionHeader('Developer & Data'), _buildSettingsCard([ Consumer( builder: (context, appProvider, child) => ListenableBuilder( listenable: appProvider.trafficStatsReportingService, builder: (context, child) => TrafficStatsReportingSection( service: appProvider.trafficStatsReportingService, ), ), ), 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, ), ), 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), ), ), ), 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), ), ), ), ], ), ), ]), ], ), ); } 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) { return Card( margin: EdgeInsets.zero, child: Column( children: ListTile.divideTiles( context: context, tiles: children, color: Theme.of(context).dividerColor.withValues(alpha: 0.4), ).toList(), ), ); } Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) { final sourceBytes = _previewSourceBytes; final fileName = _previewSourceName ?? 'No image selected'; final radioBw = connectionProvider.deviceInfo.radioBw; final radioSf = connectionProvider.deviceInfo.radioSf; final radioCr = connectionProvider.deviceInfo.radioCr; final bwHz = _resolveBandwidthHz(radioBw); final previewSizeBytes = _previewCompressedBytes?.length ?? 0; final directChunk = safeImageDataBytesForPath(0); final twoHopChunk = safeImageDataBytesForPath(2); final directFragments = previewSizeBytes > 0 ? (previewSizeBytes + directChunk - 1) ~/ directChunk : 0; final twoHopFragments = previewSizeBytes > 0 ? (previewSizeBytes + twoHopChunk - 1) ~/ twoHopChunk : 0; final imageDirect = estimateImageTransmitDuration( fragmentCount: directFragments, sizeBytes: previewSizeBytes, pathLen: 0, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, ); final imageTwoHop = estimateImageTransmitDuration( fragmentCount: twoHopFragments, sizeBytes: previewSizeBytes, pathLen: 2, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, ); return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ Icon(Icons.photo_library_outlined), SizedBox(width: 8), Text( 'Image mode preview', style: TextStyle(fontWeight: FontWeight.w700), ), ], ), const SizedBox(height: 6), Row( children: [ Expanded( child: Text( 'Source image', style: Theme.of(context).textTheme.bodySmall, ), ), FilledButton.icon( onPressed: _isPreviewLoading ? null : _selectPreviewImageFromGallery, icon: Icon(Icons.photo_library_outlined), label: Text(AppLocalizations.of(context)!.selectFromGallery), ), ], ), const SizedBox(height: 6), Text( fileName, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.8), ), ), if (previewSizeBytes > 0) ...[ const SizedBox(height: 4), Text( 'Preview tx (this image): Direct ${_formatEstimateDuration(imageDirect)} • 2-hop ${_formatEstimateDuration(imageTwoHop)}', style: Theme.of(context).textTheme.bodySmall, ), Text( 'Radio BW ${_formatBandwidthLabel(bwHz)} · SF ${radioSf ?? 10} · CR ${radioCr ?? 5}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.7), ), ), ], const SizedBox(height: 10), Row( children: [ Expanded( child: Text( _showCurrentImagePreview ? 'Showing: current image mode' : 'Showing: source image', style: Theme.of(context).textTheme.bodySmall, ), ), Switch( value: _showCurrentImagePreview, onChanged: (value) { setState(() => _showCurrentImagePreview = value); }, ), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(8), child: AspectRatio( aspectRatio: 1, child: _showCurrentImagePreview ? (_isPreviewLoading ? const Center( child: CircularProgressIndicator(strokeWidth: 2), ) : (_previewCompressedBytes != null ? AvifImage.memory( _previewCompressedBytes!, fit: BoxFit.cover, ) : Container(color: Colors.black12))) : (sourceBytes != null ? Image.memory(sourceBytes, fit: BoxFit.cover) : Container(color: Colors.black12)), ), ), ], ), ), ); } String _formatEstimateDuration(Duration value) { if (value.inSeconds < 60) return '~${value.inSeconds}s'; return '~${value.inMinutes}m ${value.inSeconds % 60}s'; } static String _formatBandwidthLabel(int bwHz) { if (bwHz >= 1000000) return '${(bwHz / 1000000).toStringAsFixed(2)} MHz'; if (bwHz >= 1000) return '${(bwHz / 1000).toStringAsFixed(1)} kHz'; return '$bwHz Hz'; } int _resolveBandwidthHz(int? rawBw) { if (rawBw == null) return 250000; if (rawBw > 1000) return rawBw; switch (rawBw) { case 0: return 7800; case 1: return 10400; case 2: return 15600; case 3: return 20800; case 4: return 31250; case 5: return 41700; case 6: return 62500; case 7: return 125000; case 8: return 250000; case 9: return 500000; default: return 250000; } } Widget _buildVoiceStatsCard({ required int bitrate, required ConnectionProvider connectionProvider, required bool bandPassEnabled, required bool compressorEnabled, required bool limiterEnabled, required bool autoGainEnabled, required bool echoCancellationEnabled, required bool noiseSuppressionEnabled, required bool silenceTrimEnabled, }) { final supported = VoiceBitratePreferences.supportedBitrates; final minBitrate = supported.reduce((a, b) => a < b ? a : b).toDouble(); final maxBitrate = supported.reduce((a, b) => a > b ? a : b).toDouble(); final normalized = maxBitrate > minBitrate ? ((bitrate - minBitrate) / (maxBitrate - minBitrate)).clamp(0.0, 1.0) : 1.0; final enabledCount = (bandPassEnabled ? 1 : 0) + (compressorEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) + (autoGainEnabled ? 1 : 0) + (echoCancellationEnabled ? 1 : 0) + (noiseSuppressionEnabled ? 1 : 0) + (silenceTrimEnabled ? 1 : 0); final radioBw = connectionProvider.deviceInfo.radioBw; final radioSf = connectionProvider.deviceInfo.radioSf; final radioCr = connectionProvider.deviceInfo.radioCr; final bwHz = _resolveBandwidthHz(radioBw); final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); const voicePreviewMs = 10000; // 10-second reference clip final packetDurationMs = voiceMode.packetDurationMs; final voicePacketCount = (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; final voiceDirect = estimateVoiceTransmitDuration( mode: voiceMode, packetCount: voicePacketCount, durationMs: voicePreviewMs, pathLen: 0, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, ); final voiceTwoHop = estimateVoiceTransmitDuration( mode: voiceMode, packetCount: voicePacketCount, durationMs: voicePreviewMs, pathLen: 2, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, ); return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Voice Processing Stats', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), Text( 'Codec: ${voiceMode.label} · $bitrate bps', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 4), Text( 'Preview tx (10s ${voiceMode.label}): Direct ${_formatEstimateDuration(voiceDirect)} • 2-hop ${_formatEstimateDuration(voiceTwoHop)}', style: Theme.of(context).textTheme.bodySmall, ), Text( 'Radio BW ${_formatBandwidthLabel(bwHz)} · SF ${radioSf ?? 10} · CR ${radioCr ?? 5}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.7), ), ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator(value: normalized, minHeight: 8), ), const SizedBox(height: 10), Row( children: [ Expanded( child: _voiceStatChip( label: AppLocalizations.of(context)!.bandpass, enabled: bandPassEnabled, ), ), SizedBox(width: 8), Expanded( child: _voiceStatChip( label: AppLocalizations.of(context)!.compressor, enabled: compressorEnabled, ), ), SizedBox(width: 8), Expanded( child: _voiceStatChip( label: AppLocalizations.of(context)!.limiter, enabled: limiterEnabled, ), ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: _voiceStatChip( label: AppLocalizations.of(context)!.autoGain, enabled: autoGainEnabled, ), ), SizedBox(width: 8), Expanded( child: _voiceStatChip( label: AppLocalizations.of(context)!.echoCancel, enabled: echoCancellationEnabled, ), ), SizedBox(width: 8), Expanded( child: _voiceStatChip( label: AppLocalizations.of(context)!.noiseSuppress, enabled: noiseSuppressionEnabled, ), ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: _voiceStatChip( label: AppLocalizations.of(context)!.silenceTrim, enabled: silenceTrimEnabled, ), ), ], ), const SizedBox(height: 8), Text( 'Processing enabled: $enabledCount/7', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ); } Widget _voiceStatChip({required String label, required bool enabled}) { final color = enabled ? Colors.green : Colors.grey; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), border: Border.all(color: color.withValues(alpha: 0.4)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( enabled ? Icons.check_circle : Icons.radio_button_unchecked, size: 16, color: color, ), const SizedBox(width: 6), Flexible( child: Text( label, overflow: TextOverflow.ellipsis, style: TextStyle(color: color, fontWeight: FontWeight.w600), ), ), ], ), ); } void _showThemeDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.chooseTheme), content: SingleChildScrollView( child: RadioGroup( groupValue: _selectedTheme, onChanged: (value) { _handleThemeChange(value); Navigator.pop(context); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ RadioListTile( title: Text(AppLocalizations.of(context)!.light), subtitle: Text(AppLocalizations.of(context)!.blueLightTheme), value: AppThemeMode.light, ), RadioListTile( title: Text(AppLocalizations.of(context)!.dark), subtitle: Text(AppLocalizations.of(context)!.blueDarkTheme), value: AppThemeMode.dark, ), Divider(), RadioListTile( title: Row( children: [ Text(AppLocalizations.of(context)!.sarRed), const SizedBox(width: 8), Container( width: 16, height: 16, decoration: BoxDecoration( color: const Color(0xFFFF5252), shape: BoxShape.circle, border: Border.all(color: Colors.black26), ), ), ], ), subtitle: Text( AppLocalizations.of(context)!.alertEmergencyMode, ), value: AppThemeMode.sarRed, ), RadioListTile( title: Row( children: [ Text(AppLocalizations.of(context)!.sarGreen), const SizedBox(width: 8), Container( width: 16, height: 16, decoration: BoxDecoration( color: const Color(0xFF69F0AE), shape: BoxShape.circle, border: Border.all(color: Colors.black26), ), ), ], ), subtitle: Text( AppLocalizations.of(context)!.safeAllClearMode, ), value: AppThemeMode.sarGreen, ), RadioListTile( title: Row( children: [ Text(AppLocalizations.of(context)!.sarNavyBlue), const SizedBox(width: 8), Container( width: 16, height: 16, decoration: BoxDecoration( color: const Color(0xFF5C9FFF), shape: BoxShape.circle, border: Border.all(color: Colors.black26), ), ), ], ), subtitle: Text( AppLocalizations.of(context)!.sarNavyBlueDescription, ), value: AppThemeMode.sarNavyBlue, ), Divider(), RadioListTile( title: Text(AppLocalizations.of(context)!.autoSystem), subtitle: Text( AppLocalizations.of(context)!.followSystemTheme, ), value: AppThemeMode.system, ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.cancel), ), ], ), ); } void _showLanguageDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.chooseLanguage), content: SingleChildScrollView( child: RadioGroup( groupValue: _selectedLocale, onChanged: (value) { _handleLocaleChange(value); Navigator.pop(context); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ RadioListTile( title: Text(LocalePreferences.getDisplayName(null)), subtitle: Text(LocalePreferences.getDisplayName(null)), value: null, ), const Divider(), ...LocalePreferences.supportedLocales.map((locale) { return RadioListTile( title: Text(LocalePreferences.getNativeDisplayName(locale)), value: locale, ); }), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.cancel), ), ], ), ); } void _showImageMaxSizeDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.maxImageSize), content: SingleChildScrollView( child: RadioGroup( groupValue: _imageMaxSize, onChanged: (value) { if (value != null) _saveImageMaxSize(value); Navigator.pop(context); }, child: Column( mainAxisSize: MainAxisSize.min, children: ImagePreferences.supportedSizes .map( (size) => RadioListTile( value: size, title: Text('$size×$size px'), subtitle: size == ImagePreferences.defaultMaxSize ? Text(AppLocalizations.of(context)!.defaultValue) : null, ), ) .toList(), ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.cancel), ), ], ), ); } void _showVoiceBitrateDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.voiceBitrate), content: SingleChildScrollView( child: RadioGroup( groupValue: _voiceBitrate, onChanged: (value) { if (value != null) { _saveVoiceBitratePreference(value); } Navigator.pop(context); }, child: Column( mainAxisSize: MainAxisSize.min, children: VoiceBitratePreferences.supportedBitrates .map( (bitrate) => RadioListTile( value: bitrate, title: Text('$bitrate bps'), subtitle: bitrate == VoiceBitratePreferences.defaultBitrate ? Text(AppLocalizations.of(context)!.defaultValue) : null, ), ) .toList(), ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.cancel), ), ], ), ); } void _showAboutDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'MeshCore SAR', style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( 'Version ${_packageInfo?.version ?? '1.0.0'}', style: Theme.of(context).textTheme.bodyMedium, ), SizedBox(height: 16), Text(AppLocalizations.of(context)!.aboutDescription), SizedBox(height: 16), Text( AppLocalizations.of(context)!.technologiesUsed, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), SizedBox(height: 8), Text(AppLocalizations.of(context)!.technologiesList), ], ), ), actions: [ TextButton.icon( onPressed: () async { final url = Uri.parse('https://dz0ny.dev/posts/meshcore-sar/'); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } }, icon: Icon(Icons.open_in_new), label: Text(AppLocalizations.of(context)!.moreInfo), ), TextButton( onPressed: () => Navigator.pop(context), child: Text(AppLocalizations.of(context)!.close), ), ], ), ); } }