From 363a75595eb77e80b5551209bc407c990454534f Mon Sep 17 00:00:00 2001 From: Janez T Date: Mon, 13 Oct 2025 23:11:06 +0200 Subject: [PATCH] Implement theme management and settings screen; enhance compass dialog with location formats --- lib/main.dart | 37 ++++- lib/screens/home_screen.dart | 32 +++- lib/screens/map_tab.dart | 92 ++++++++++-- lib/screens/settings_screen.dart | 245 +++++++++++++++++++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 3 + 6 files changed, 396 insertions(+), 15 deletions(-) create mode 100644 lib/screens/settings_screen.dart diff --git a/lib/main.dart b/lib/main.dart index 827d69e..e8fe460 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'providers/connection_provider.dart'; import 'providers/contacts_provider.dart'; import 'providers/messages_provider.dart'; @@ -12,9 +13,39 @@ void main() { runApp(const MeshCoreSarApp()); } -class MeshCoreSarApp extends StatelessWidget { +class MeshCoreSarApp extends StatefulWidget { const MeshCoreSarApp({super.key}); + @override + State createState() => _MeshCoreSarAppState(); +} + +class _MeshCoreSarAppState extends State { + ThemeMode _themeMode = ThemeMode.system; + + @override + void initState() { + super.initState(); + _loadThemePreference(); + } + + Future _loadThemePreference() async { + final prefs = await SharedPreferences.getInstance(); + final themeName = prefs.getString('theme_mode') ?? 'system'; + setState(() { + _themeMode = ThemeMode.values.firstWhere( + (mode) => mode.name == themeName, + orElse: () => ThemeMode.system, + ); + }); + } + + void _handleThemeChanged(ThemeMode mode) { + setState(() { + _themeMode = mode; + }); + } + @override Widget build(BuildContext context) { return MultiProvider( @@ -96,8 +127,8 @@ class MeshCoreSarApp extends StatelessWidget { filled: true, ), ), - themeMode: ThemeMode.system, - home: const HomeScreen(), + themeMode: _themeMode, + home: HomeScreen(onThemeChanged: _handleThemeChanged, currentTheme: _themeMode), ), ); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 54c9bd5..3b7e1c3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -8,9 +8,17 @@ import 'messages_tab.dart'; import 'contacts_tab.dart'; import 'map_tab.dart'; import 'map_management_screen.dart'; +import 'settings_screen.dart'; class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); + final Function(ThemeMode) onThemeChanged; + final ThemeMode currentTheme; + + const HomeScreen({ + super.key, + required this.onThemeChanged, + required this.currentTheme, + }); @override State createState() => _HomeScreenState(); @@ -247,6 +255,28 @@ class _HomeScreenState extends State with SingleTickerProviderStateM }); }, ), + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.settings), + SizedBox(width: 8), + Text('Settings'), + ], + ), + onTap: () { + Future.delayed(Duration.zero, () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SettingsScreen( + onThemeChanged: widget.onThemeChanged, + currentTheme: widget.currentTheme, + ), + ), + ); + }); + }, + ), ], ), ], diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 32d6249..f806131 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1000,26 +1000,22 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header + // Header - just close button Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.end, children: [ - Text( - 'Compass View', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), ), ], ), - const SizedBox(height: 16), // Heading and Elevation info _buildInfoRow(context, heading, position), - const SizedBox(height: 24), + const SizedBox(height: 16), + // Current location in multiple formats + if (position != null) _buildLocationFormats(context, position), + const SizedBox(height: 16), // Large compass SizedBox( width: 300, @@ -1090,6 +1086,82 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { ); } + Widget _buildLocationFormats(BuildContext context, Position position) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current Location', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + _buildCoordinateRow( + context, + 'WGS84 (DD)', + '${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}', + ), + _buildCoordinateRow( + context, + 'WGS84 (DMS)', + '${_formatDMS(position.latitude, true)}, ${_formatDMS(position.longitude, false)}', + ), + ], + ), + ); + } + + Widget _buildCoordinateRow(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + } + + // Convert decimal degrees to DMS (Degrees, Minutes, Seconds) + String _formatDMS(double degrees, bool isLatitude) { + final direction = isLatitude + ? (degrees >= 0 ? 'N' : 'S') + : (degrees >= 0 ? 'E' : 'W'); + + final absolute = degrees.abs(); + final deg = absolute.floor(); + final minDecimal = (absolute - deg) * 60; + final min = minDecimal.floor(); + final sec = (minDecimal - min) * 60; + + return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; + } + Widget _buildContactsList(BuildContext context, double? heading, Position? position) { if (position == null) { return const Text('Location unavailable'); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..75d0f96 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class SettingsScreen extends StatefulWidget { + final Function(ThemeMode) onThemeChanged; + final ThemeMode currentTheme; + + const SettingsScreen({ + super.key, + required this.onThemeChanged, + required this.currentTheme, + }); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late ThemeMode _selectedTheme; + PackageInfo? _packageInfo; + + @override + void initState() { + super.initState(); + _selectedTheme = widget.currentTheme; + _loadPackageInfo(); + } + + Future _loadPackageInfo() async { + final info = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() { + _packageInfo = info; + }); + } + } + + Future _saveThemePreference(ThemeMode theme) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('theme_mode', theme.name); + } + + void _handleThemeChange(ThemeMode? theme) { + if (theme != null) { + setState(() { + _selectedTheme = theme; + }); + _saveThemePreference(theme); + widget.onThemeChanged(theme); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: ListView( + children: [ + // General Settings Section + _buildSectionHeader('General'), + ListTile( + leading: const Icon(Icons.palette), + title: const Text('Theme'), + subtitle: Text(_getThemeLabel(_selectedTheme)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showThemeDialog(), + ), + const Divider(), + + // About Section + _buildSectionHeader('About'), + ListTile( + leading: const Icon(Icons.info), + title: const Text('App Version'), + subtitle: Text( + _packageInfo != null + ? '${_packageInfo!.version} (${_packageInfo!.buildNumber})' + : 'Loading...', + ), + ), + ListTile( + leading: const Icon(Icons.badge), + title: const Text('App Name'), + subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'), + ), + ListTile( + leading: const Icon(Icons.description), + title: const Text('About MeshCore SAR'), + subtitle: const Text( + 'Search & Rescue application with BLE mesh networking and offline maps', + ), + onTap: () => _showAboutDialog(), + ), + const Divider(), + + // Developer Section + _buildSectionHeader('Developer'), + ListTile( + leading: const Icon(Icons.bug_report), + title: const Text('Package Name'), + subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'), + ), + ], + ), + ); + } + + 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, + ), + ), + ); + } + + String _getThemeLabel(ThemeMode mode) { + switch (mode) { + case ThemeMode.light: + return 'Light'; + case ThemeMode.dark: + return 'Dark'; + case ThemeMode.system: + return 'Auto (System)'; + } + } + + void _showThemeDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Choose Theme'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: const Text('Light'), + subtitle: const Text('Always use light theme'), + value: ThemeMode.light, + groupValue: _selectedTheme, + onChanged: (value) { + _handleThemeChange(value); + Navigator.pop(context); + }, + ), + RadioListTile( + title: const Text('Dark'), + subtitle: const Text('Always use dark theme'), + value: ThemeMode.dark, + groupValue: _selectedTheme, + onChanged: (value) { + _handleThemeChange(value); + Navigator.pop(context); + }, + ), + RadioListTile( + title: const Text('Auto (System)'), + subtitle: const Text('Follow system theme'), + value: ThemeMode.system, + groupValue: _selectedTheme, + onChanged: (value) { + _handleThemeChange(value); + Navigator.pop(context); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ); + } + + void _showAboutDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('About MeshCore SAR'), + 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), + const Text( + 'A Search & Rescue application designed for emergency response teams. ' + 'Features include:\n\n' + '• BLE mesh networking for device-to-device communication\n' + '• Offline maps with multiple layer options\n' + '• Real-time team member tracking\n' + '• SAR tactical markers (found person, fire, staging)\n' + '• Contact management and messaging\n' + '• GPS tracking with compass heading\n' + '• Map tile caching for offline use', + ), + const SizedBox(height: 16), + Text( + 'Technologies Used:', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + '• Flutter for cross-platform development\n' + '• BLE (Bluetooth Low Energy) for mesh networking\n' + '• OpenStreetMap for mapping\n' + '• Provider for state management\n' + '• SharedPreferences for local storage', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index a62ff6a..77e2512 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -473,7 +473,7 @@ packages: source: hosted version: "4.3.1" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" diff --git a/pubspec.yaml b/pubspec.yaml index 570d738..33a8895 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,9 @@ dependencies: # Persistent storage shared_preferences: ^2.3.3 + # Package info + package_info_plus: ^8.1.2 + dev_dependencies: flutter_test: sdk: flutter