mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Implement theme management and settings screen; enhance compass dialog with location formats
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'providers/connection_provider.dart';
|
import 'providers/connection_provider.dart';
|
||||||
import 'providers/contacts_provider.dart';
|
import 'providers/contacts_provider.dart';
|
||||||
import 'providers/messages_provider.dart';
|
import 'providers/messages_provider.dart';
|
||||||
@@ -12,9 +13,39 @@ void main() {
|
|||||||
runApp(const MeshCoreSarApp());
|
runApp(const MeshCoreSarApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MeshCoreSarApp extends StatelessWidget {
|
class MeshCoreSarApp extends StatefulWidget {
|
||||||
const MeshCoreSarApp({super.key});
|
const MeshCoreSarApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MeshCoreSarApp> createState() => _MeshCoreSarAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||||
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadThemePreference();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
@@ -96,8 +127,8 @@ class MeshCoreSarApp extends StatelessWidget {
|
|||||||
filled: true,
|
filled: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: _themeMode,
|
||||||
home: const HomeScreen(),
|
home: HomeScreen(onThemeChanged: _handleThemeChanged, currentTheme: _themeMode),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ import 'messages_tab.dart';
|
|||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
import 'map_tab.dart';
|
import 'map_tab.dart';
|
||||||
import 'map_management_screen.dart';
|
import 'map_management_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
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
|
@override
|
||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
@@ -247,6 +255,28 @@ class _HomeScreenState extends State<HomeScreen> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1000,26 +1000,22 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Header
|
// Header - just close button
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
|
||||||
'Compass View',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
// Heading and Elevation info
|
// Heading and Elevation info
|
||||||
_buildInfoRow(context, heading, position),
|
_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
|
// Large compass
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 300,
|
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) {
|
Widget _buildContactsList(BuildContext context, double? heading, Position? position) {
|
||||||
if (position == null) {
|
if (position == null) {
|
||||||
return const Text('Location unavailable');
|
return const Text('Location unavailable');
|
||||||
|
|||||||
245
lib/screens/settings_screen.dart
Normal file
245
lib/screens/settings_screen.dart
Normal file
@@ -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<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
late ThemeMode _selectedTheme;
|
||||||
|
PackageInfo? _packageInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedTheme = widget.currentTheme;
|
||||||
|
_loadPackageInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPackageInfo() async {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_packageInfo = info;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<ThemeMode>(
|
||||||
|
title: const Text('Light'),
|
||||||
|
subtitle: const Text('Always use light theme'),
|
||||||
|
value: ThemeMode.light,
|
||||||
|
groupValue: _selectedTheme,
|
||||||
|
onChanged: (value) {
|
||||||
|
_handleThemeChange(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<ThemeMode>(
|
||||||
|
title: const Text('Dark'),
|
||||||
|
subtitle: const Text('Always use dark theme'),
|
||||||
|
value: ThemeMode.dark,
|
||||||
|
groupValue: _selectedTheme,
|
||||||
|
onChanged: (value) {
|
||||||
|
_handleThemeChange(value);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<ThemeMode>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -473,7 +473,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.3.1"
|
version: "4.3.1"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ dependencies:
|
|||||||
# Persistent storage
|
# Persistent storage
|
||||||
shared_preferences: ^2.3.3
|
shared_preferences: ^2.3.3
|
||||||
|
|
||||||
|
# Package info
|
||||||
|
package_info_plus: ^8.1.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Reference in New Issue
Block a user