mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
1337 lines
44 KiB
Dart
1337 lines
44 KiB
Dart
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: RadioGroup<int>(
|
||
groupValue: _imageMaxSize,
|
||
onChanged: (value) {
|
||
if (value != null) _saveImageMaxSize(value);
|
||
Navigator.pop(context);
|
||
},
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: ImagePreferences.supportedSizes
|
||
.map(
|
||
(size) => RadioListTile<int>(
|
||
value: size,
|
||
title: Text('$size×$size px'),
|
||
subtitle: size == ImagePreferences.defaultMaxSize
|
||
? const Text('Default')
|
||
: null,
|
||
),
|
||
)
|
||
.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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|