Files
meshcore-sar_android/lib/screens/settings_screen.dart
2026-03-01 19:25:19 +01:00

1335 lines
44 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io' show Platform;
import 'package:flutter/material.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 '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/update_checker_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart';
import '../utils/sample_data_generator.dart';
import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart';
import '../widgets/connection_mode_selector.dart';
import '../widgets/update_dialog.dart';
import 'sar_template_management_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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late AppThemeMode _selectedTheme;
late Locale? _selectedLocale;
PackageInfo? _packageInfo;
bool _isLoadingSampleData = false;
bool _showRxTxIndicators = true;
bool _isCheckingForUpdates = false;
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
int _imageMaxSize = ImagePreferences.defaultMaxSize;
int _imageCompression = ImagePreferences.defaultQuality;
bool _imageGrayscale = ImagePreferences.defaultGrayscale;
final LocationTrackingService _locationService = LocationTrackingService();
@override
void initState() {
super.initState();
_selectedTheme = widget.currentTheme;
_selectedLocale = widget.currentLocale;
_loadPackageInfo();
_initializeLocationService();
_loadRxTxPreference();
_loadVoiceBitratePreference();
_loadImagePreferences();
}
@override
void dispose() {
// Clear location service callbacks to prevent memory leaks
_locationService.onError = null;
_locationService.onBroadcastSent = null;
_locationService.onTrackingStateChanged = null;
super.dispose();
}
Future<void> _loadPackageInfo() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_packageInfo = info;
});
}
}
Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
});
}
}
Future<void> _saveRxTxPreference(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('show_rx_tx_indicators', value);
}
Future<void> _loadVoiceBitratePreference() async {
final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
_voiceBitrate = value;
});
}
Future<void> _saveVoiceBitratePreference(int value) async {
await VoiceBitratePreferences.setBitrate(value);
if (!mounted) return;
setState(() {
_voiceBitrate = value;
});
}
String _voiceBitrateSubtitle(int bitrate) {
return '$bitrate bps';
}
Future<void> _loadImagePreferences() async {
final size = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression();
final grayscale = await ImagePreferences.getGrayscale();
if (!mounted) return;
setState(() {
_imageMaxSize = size;
_imageCompression = compression;
_imageGrayscale = grayscale;
});
}
Future<void> _saveImageMaxSize(int size) async {
await ImagePreferences.setMaxSize(size);
if (!mounted) return;
setState(() => _imageMaxSize = size);
}
Future<void> _saveImageCompression(int compression) async {
await ImagePreferences.setCompression(compression);
if (!mounted) return;
setState(() => _imageCompression = compression);
}
Future<void> _initializeLocationService() async {
// Initialize location service with BLE service
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
final appProvider = context.read<AppProvider>();
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('background_tracking_enabled') ?? false;
if (wasTracking) {
await _startBackgroundTracking();
}
if (mounted) {
setState(() {});
}
}
});
}
Future<void> _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<void> _saveLocalePreference(Locale? locale) async {
await LocalePreferences.setLocale(locale);
}
/// Check for app updates and show notification or dialog
Future<void> _checkForUpdates() async {
// Only on Android
if (!Platform.isAndroid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Update check is only available on Android'),
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(
const SnackBar(
content: Text('You are running the latest version'),
backgroundColor: Colors.green,
),
);
return;
}
if (updateInfo.downloadUrl == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Update available but download URL not found'),
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<void> _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 sarMessages = SampleDataGenerator.generateSarMarkerMessages(
centerLocation: centerLocation,
l10n: l10n,
foundPersonCount: 2,
fireCount: 1,
stagingCount: 1,
objectCount: 1,
);
final channelMessages = SampleDataGenerator.generateChannelMessages(
centerLocation: centerLocation,
l10n: l10n,
generalChannelMessages: 8,
emergencyChannelMessages: 5,
);
// Combine all messages
final allMessages = [...sarMessages, ...channelMessages];
// Add to providers
final contactsProvider = Provider.of<ContactsProvider>(
context,
listen: false,
);
final messagesProvider = Provider.of<MessagesProvider>(
context,
listen: false,
);
contactsProvider.addContacts(contacts);
messagesProvider.addMessages(allMessages);
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,
sarMessages.length,
channelMessages.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<void> _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),
const 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<void> _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<void> _clearSampleData() async {
final confirmed = await showDialog<bool>(
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<ContactsProvider>(
context,
listen: false,
);
final messagesProvider = Provider.of<MessagesProvider>(
context,
listen: false,
);
contactsProvider.clearContacts();
messagesProvider.clearAll();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.allDataCleared),
backgroundColor: Colors.orange,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
body: ListView(
children: [
// General Settings Section
_buildSectionHeader(AppLocalizations.of(context)!.general),
ListTile(
leading: const Icon(Icons.palette),
title: Text(AppLocalizations.of(context)!.theme),
subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeDialog(),
),
SwitchListTile(
secondary: const 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);
},
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.simpleMode),
subtitle: Text(
AppLocalizations.of(context)!.simpleModeDescription,
),
value: appProvider.isSimpleMode,
onChanged: (value) async {
await appProvider.toggleSimpleMode(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const 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);
},
),
),
ListTile(
leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language),
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
),
const Divider(),
// Voice Settings Section
_buildSectionHeader('Voice'),
Consumer<AppProvider>(
builder: (context, appProvider, child) => _buildVoiceStatsCard(
bitrate: _voiceBitrate,
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Voice bitrate'),
subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)),
trailing: const Icon(Icons.chevron_right),
onTap: _showVoiceBitrateDialog,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.tune),
title: const Text('Band-pass filter voice'),
subtitle: const Text(
'Keeps speech frequencies and cuts low/high noise',
),
value: appProvider.isVoiceBandPassFilterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceBandPassFilterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.compress),
title: const Text('Voice compressor'),
subtitle: const Text('Balances quiet and loud speech levels'),
value: appProvider.isVoiceCompressorEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceCompressorEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.speed),
title: const Text('Voice limiter'),
subtitle: const Text('Prevents clipping peaks before encoding'),
value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
title: const Text('Trim silence in voice messages'),
subtitle: const Text(
'Removes long silent parts before sending voice',
),
value: appProvider.isVoiceSilenceTrimmingEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceSilenceTrimmingEnabled(value);
},
),
),
const Divider(),
// Image Settings Section
_buildSectionHeader('Image'),
ListTile(
leading: const Icon(Icons.photo_size_select_large),
title: const Text('Max image size'),
subtitle: Text('$_imageMaxSize×$_imageMaxSize px'),
trailing: const Icon(Icons.chevron_right),
onTap: _showImageMaxSizeDialog,
),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('Image compression'),
subtitle: Text('$_imageCompression / 90 (higher = smaller file)'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
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: const Icon(Icons.invert_colors),
title: const Text('Grayscale'),
subtitle: const Text(
'Converts image to grayscale for smaller file size',
),
value: _imageGrayscale,
onChanged: (value) async {
await ImagePreferences.setGrayscale(value);
setState(() => _imageGrayscale = value);
},
),
const Divider(),
// Templates Section
_buildSectionHeader('Templates'),
ListTile(
leading: const 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: const Icon(Icons.school),
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
// Show wizard without resetting state - just as a modal
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WelcomeWizardScreen(
onCompleted: () {
// Just pop back to settings when done
Navigator.of(context).pop();
},
),
),
);
},
),
const Divider(),
// Network Sharing Section
const ConnectionModeSelector(),
const Divider(),
// Permissions Section
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
ListTile(
leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),
subtitle: FutureBuilder<LocationPermission>(
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(),
),
const Divider(),
// About Section
_buildSectionHeader(AppLocalizations.of(context)!.about),
ListTile(
leading: const Icon(Icons.info),
title: Text(AppLocalizations.of(context)!.appVersion),
subtitle: Text(
_packageInfo != null
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: 'Loading...',
),
),
// Check for Updates button (Android only)
if (Platform.isAndroid)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 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),
),
),
),
ListTile(
leading: const Icon(Icons.badge),
title: Text(AppLocalizations.of(context)!.appName),
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
),
ListTile(
leading: const Icon(Icons.description),
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
subtitle: Text(
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
),
onTap: () => _showAboutDialog(),
),
const Divider(),
// Developer Section
_buildSectionHeader(AppLocalizations.of(context)!.developer),
ListTile(
leading: const Icon(Icons.bug_report),
title: Text(AppLocalizations.of(context)!.packageName),
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
),
const Divider(),
// Sample Data Section
_buildSectionHeader(AppLocalizations.of(context)!.sampleData),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 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.symmetric(horizontal: 16, vertical: 8),
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(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: const 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(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildVoiceStatsCard({
required int bitrate,
required bool bandPassEnabled,
required bool compressorEnabled,
required bool limiterEnabled,
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) +
(silenceTrimEnabled ? 1 : 0);
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(
'Bitrate: $bitrate bps',
style: Theme.of(context).textTheme.bodySmall,
),
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: 'Band-pass',
enabled: bandPassEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Compressor',
enabled: compressorEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Limiter',
enabled: limiterEnabled,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Silence trim',
enabled: silenceTrimEnabled,
),
),
],
),
const SizedBox(height: 8),
Text(
'Processing enabled: $enabledCount/4',
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<AppThemeMode>(
groupValue: _selectedTheme,
onChanged: (value) {
_handleThemeChange(value);
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.light),
subtitle: Text(AppLocalizations.of(context)!.blueLightTheme),
value: AppThemeMode.light,
),
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.dark),
subtitle: Text(AppLocalizations.of(context)!.blueDarkTheme),
value: AppThemeMode.dark,
),
const Divider(),
RadioListTile<AppThemeMode>(
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<AppThemeMode>(
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<AppThemeMode>(
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,
),
const Divider(),
RadioListTile<AppThemeMode>(
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<Locale?>(
groupValue: _selectedLocale,
onChanged: (value) {
_handleLocaleChange(value);
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<Locale?>(
title: Text(LocalePreferences.getDisplayName(null)),
subtitle: Text(LocalePreferences.getDisplayName(null)),
value: null,
),
const Divider(),
...LocalePreferences.supportedLocales.map((locale) {
return RadioListTile<Locale?>(
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: const Text('Max image size'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: ImagePreferences.supportedSizes
.map(
(size) => RadioListTile<int>(
value: size,
groupValue: _imageMaxSize,
title: Text('${size}×$size px'),
subtitle: size == ImagePreferences.defaultMaxSize
? const Text('Default')
: null,
onChanged: (value) {
if (value != null) _saveImageMaxSize(value);
Navigator.pop(context);
},
),
)
.toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
],
),
);
}
void _showVoiceBitrateDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Voice bitrate'),
content: SingleChildScrollView(
child: RadioGroup<int>(
groupValue: _voiceBitrate,
onChanged: (value) {
if (value != null) {
_saveVoiceBitratePreference(value);
}
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: VoiceBitratePreferences.supportedBitrates
.map(
(bitrate) => RadioListTile<int>(
value: bitrate,
title: Text('$bitrate bps'),
subtitle:
bitrate == VoiceBitratePreferences.defaultBitrate
? const Text('Default')
: 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,
),
const SizedBox(height: 16),
Text(AppLocalizations.of(context)!.aboutDescription),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.technologiesUsed,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const 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: const Icon(Icons.open_in_new),
label: Text(AppLocalizations.of(context)!.moreInfo),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
),
);
}
}