feat: MeshCore SAR - Flutter BLE mesh radio companion app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janez T
2026-02-28 10:11:33 +01:00
commit baf49d27d8
313 changed files with 101770 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import '../../l10n/app_localizations.dart';
/// Reusable location display widget with tap-to-show modal
/// Shows coordinates in a compact format with ability to view all formats
class LocationDisplay extends StatelessWidget {
final LatLng location;
final bool compact;
const LocationDisplay({
super.key,
required this.location,
this.compact = true,
});
@override
Widget build(BuildContext context) {
if (compact) {
return GestureDetector(
onTap: () => _showLocationFormats(context),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.location_on, size: 18),
const SizedBox(width: 6),
Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Icon(
Icons.open_in_new,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
);
}
// Non-compact version (just text)
return Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
),
);
}
void _showLocationFormats(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Location Formats',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Decimal Degrees (DD)
_buildFormatRow(
context,
'DD (Decimal Degrees)',
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}',
),
// Degrees Minutes Seconds (DMS)
_buildFormatRow(
context,
'DMS (Degrees Minutes Seconds)',
_convertToDMS(location.latitude, location.longitude),
),
// Degrees Decimal Minutes (DDM)
_buildFormatRow(
context,
'DDM (Degrees Decimal Minutes)',
_convertToDDM(location.latitude, location.longitude),
),
// MGRS (Military Grid Reference System)
_buildFormatRow(
context,
'MGRS (Military Grid)',
_convertToMGRS(location.latitude, location.longitude),
),
// Google Plus Code
_buildFormatRow(
context,
'Plus Code',
_convertToPlusCode(location.latitude, location.longitude),
),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _buildFormatRow(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
duration: const Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
),
),
Icon(
Icons.copy,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
],
),
);
}
/// Convert to Degrees Minutes Seconds (DMS) format
String _convertToDMS(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S';
String lonDir = lon >= 0 ? 'E' : 'W';
lat = lat.abs();
lon = lon.abs();
int latDeg = lat.floor();
double latMinDec = (lat - latDeg) * 60;
int latMin = latMinDec.floor();
double latSec = (latMinDec - latMin) * 60;
int lonDeg = lon.floor();
double lonMinDec = (lon - lonDeg) * 60;
int lonMin = lonMinDec.floor();
double lonSec = (lonMinDec - lonMin) * 60;
return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir';
}
/// Convert to Degrees Decimal Minutes (DDM) format
String _convertToDDM(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S';
String lonDir = lon >= 0 ? 'E' : 'W';
lat = lat.abs();
lon = lon.abs();
int latDeg = lat.floor();
double latMin = (lat - latDeg) * 60;
int lonDeg = lon.floor();
double lonMin = (lon - lonDeg) * 60;
return '$latDeg° ${latMin.toStringAsFixed(4)}\'$latDir, $lonDeg° ${lonMin.toStringAsFixed(4)}\'$lonDir';
}
/// Convert to MGRS (Military Grid Reference System) format
/// Simplified implementation - returns approximate grid zone
String _convertToMGRS(double lat, double lon) {
// Zone number (1-60)
int zone = ((lon + 180) / 6).floor() + 1;
// Zone letter (C-X, excluding I and O)
const letters = 'CDEFGHJKLMNPQRSTUVWX';
int letterIndex = ((lat + 80) / 8).floor();
if (letterIndex < 0) letterIndex = 0;
if (letterIndex >= letters.length) letterIndex = letters.length - 1;
String letter = letters[letterIndex];
// Simplified - just show zone designation
// Full MGRS would require UTM conversion library
return '$zone$letter (approximate)';
}
/// Convert to Google Plus Code format
/// Simplified implementation - returns approximate code
String _convertToPlusCode(double lat, double lon) {
// This is a simplified version - full Plus Code requires the open_location_code package
const base = '23456789CFGHJMPQRVWX';
// Normalize coordinates
lat = (lat + 90) / 180; // 0 to 1
lon = (lon + 180) / 360; // 0 to 1
String code = '';
for (int i = 0; i < 8; i++) {
if (i == 4) code += '+';
int latDigit = (lat * 20).floor() % 20;
int lonDigit = (lon * 20).floor() % 20;
code += base[latDigit];
code += base[lonDigit];
lat = (lat * 20) % 1;
lon = (lon * 20) % 1;
}
return code;
}
}

View File

@@ -0,0 +1,666 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart';
import '../providers/app_provider.dart';
import '../services/network_scanner_service.dart';
import '../l10n/app_localizations.dart';
/// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget {
const ConnectionDialog({super.key});
@override
State<ConnectionDialog> createState() => _ConnectionDialogState();
}
class _ConnectionDialogState extends State<ConnectionDialog>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final NetworkScannerService _networkScanner = NetworkScannerService();
final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0;
int _totalToScan = 0;
String? _connectingToServerUrl; // Track which server is being connected to
// Named listener method for proper cleanup
void _onTabChanged() {
if (_tabController.index == 1) {
// Switched to network tab
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
// Load cached results
setState(() {
_discoveredServers.addAll(_networkScanner.cachedServers);
});
debugPrint(
'📦 [NetworkScanner] Loaded ${_discoveredServers.length} servers from cache',
);
} else if (!_networkScanner.isScanning &&
!_networkScanner.hasCachedResults) {
// No cache, start initial scan
_startNetworkScan();
}
}
}
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
// Start BLE scan by default
final connectionProvider = Provider.of<ConnectionProvider>(
context,
listen: false,
);
connectionProvider.startScan();
// Set up network scanner callbacks
_networkScanner.onServerDiscovered = (server) {
if (mounted) {
setState(() {
// Only add if not already in the list (deduplicate)
if (!_discoveredServers.contains(server)) {
_discoveredServers.add(server);
}
});
}
};
_networkScanner.onProgressUpdate = (scanned, total) {
if (mounted) {
setState(() {
_scannedCount = scanned;
_totalToScan = total;
});
}
};
// Listen to tab changes using named method for proper cleanup
_tabController.addListener(_onTabChanged);
}
@override
void dispose() {
final connectionProvider = Provider.of<ConnectionProvider>(
context,
listen: false,
);
connectionProvider.stopScan();
_networkScanner.stopScan();
// Remove listener before disposing to prevent memory leaks
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
void _startNetworkScan() {
setState(() {
_discoveredServers.clear();
_scannedCount = 0;
_totalToScan = 0;
});
_networkScanner.clearCache(); // Clear cache before starting new scan
_networkScanner.scan();
}
Color _getSignalColor(int rssi) {
if (rssi >= -60) return Colors.green;
if (rssi >= -75) return Colors.orange;
return Colors.red;
}
@override
Widget build(BuildContext context) {
final connectionProvider = context.watch<ConnectionProvider>();
return Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Column(
children: [
Row(
children: [
IconButton(
icon: Icon(
Icons.arrow_back,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () {
Navigator.pop(context);
},
),
Expanded(
child: Text(
AppLocalizations.of(context)!.appTitle,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 48), // Balance the back button
],
),
const SizedBox(height: 8),
// Tab Bar
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'BLE Devices', icon: Icon(Icons.bluetooth)),
Tab(text: 'Network Servers', icon: Icon(Icons.wifi)),
],
),
],
),
),
// Tab Content
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// BLE Devices Tab
_buildBleDevicesTab(connectionProvider),
// Network Servers Tab
_buildNetworkServersTab(),
],
),
),
],
),
);
}
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
return Column(
children: [
// Info banner
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
AppLocalizations.of(context)!.defaultPinInfo,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 13,
),
),
),
IconButton(
icon: Icon(
Icons.refresh,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: () {
connectionProvider.stopScan();
connectionProvider.startScan();
},
),
],
),
),
// Device list
Expanded(
child:
connectionProvider.isScanning &&
connectionProvider.scannedDevices.isEmpty
? const Center(child: CircularProgressIndicator())
: connectionProvider.scannedDevices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bluetooth_searching,
size: 64,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.noDevicesFound,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
connectionProvider.stopScan();
connectionProvider.startScan();
},
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.scanAgain),
),
],
),
)
: ListView.builder(
itemCount: connectionProvider.scannedDevices.length,
itemBuilder: (context, index) {
final scannedDevice =
connectionProvider.scannedDevices[index];
final device = scannedDevice.device;
final rssi = scannedDevice.rssi;
final signalColor = _getSignalColor(rssi);
return Container(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: Icon(
Icons.bluetooth,
color: signalColor,
size: 32,
),
title: Text(
device.platformName.isNotEmpty
? device.platformName
: 'Unknown Device',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
subtitle: Row(
children: [
Text(
AppLocalizations.of(context)!.tapToConnect,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(width: 8),
Text(
'$rssi dBm',
style: TextStyle(
color: signalColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onTap: () async {
final appProvider = context.read<AppProvider>();
Navigator.pop(context);
final success = await connectionProvider.connect(
device,
);
if (success &&
connectionProvider.deviceInfo.isConnected) {
await appProvider.initialize();
}
},
),
);
},
),
),
],
);
}
Widget _buildNetworkServersTab() {
final connectionProvider = context.watch<ConnectionProvider>();
final bool showingCachedResults =
!_networkScanner.isScanning &&
_networkScanner.hasCachedResults &&
_discoveredServers.isNotEmpty;
final bool isConnectingToSse = connectionProvider.isSseClientConnecting;
final int sseReconnectAttempt =
connectionProvider.sseClientReconnectionAttempt;
final int sseMaxReconnects =
connectionProvider.sseClientMaxReconnectionAttempts;
return Column(
children: [
// SSE Reconnection banner (show when reconnecting)
if (isConnectingToSse && sseReconnectAttempt > 0)
Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onTertiaryContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Reconnecting to server... (Attempt $sseReconnectAttempt/$sseMaxReconnects)',
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiaryContainer,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
// Info banner
Container(
margin: EdgeInsets.fromLTRB(
16,
isConnectingToSse && sseReconnectAttempt > 0 ? 8 : 16,
16,
16,
),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
showingCachedResults ? Icons.cached : Icons.info_outline,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
showingCachedResults
? 'Showing cached results. Tap refresh to rescan.'
: 'Scanning local network for shared MeshCore devices on port 12929',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 13,
),
),
),
IconButton(
icon: Icon(
Icons.refresh,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: _startNetworkScan,
),
],
),
),
// Scan progress
if (_networkScanner.isScanning)
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
LinearProgressIndicator(
value: _totalToScan > 0 ? _scannedCount / _totalToScan : null,
),
const SizedBox(height: 8),
Text(
'Scanning... $_scannedCount/${_totalToScan > 0 ? _totalToScan : "?"} IPs',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
// Server list
Expanded(
child: _networkScanner.isScanning && _discoveredServers.isEmpty
? const Center(child: CircularProgressIndicator())
: _discoveredServers.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.wifi_off,
size: 64,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'No servers found',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _startNetworkScan,
icon: const Icon(Icons.refresh),
label: const Text('Scan Again'),
),
],
),
)
: ListView.builder(
itemCount: _discoveredServers.length,
itemBuilder: (context, index) {
final server = _discoveredServers[index];
final isConnectingToThisServer =
_connectingToServerUrl == server.serverUrl;
final isAnyConnectionInProgress =
isConnectingToSse || _connectingToServerUrl != null;
return Container(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isConnectingToThisServer
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
width: isConnectingToThisServer ? 2 : 1,
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: isConnectingToThisServer
? SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 3,
color: Theme.of(context).colorScheme.primary,
),
)
: const Icon(
Icons.wifi,
color: Colors.green,
size: 32,
),
title: Text(
server.ipAddress,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
isConnectingToThisServer
? 'Connecting...'
: 'Port ${server.port}${server.responseTime}ms',
style: TextStyle(
color: isConnectingToThisServer
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 14,
fontWeight: isConnectingToThisServer
? FontWeight.w500
: FontWeight.normal,
),
),
trailing: isConnectingToThisServer
? null
: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
enabled: !isAnyConnectionInProgress,
onTap: isAnyConnectionInProgress
? null
: () async {
// Capture context-dependent objects before async operations
final connectionProvider = context
.read<ConnectionProvider>();
final appProvider = context.read<AppProvider>();
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
// Mark this server as connecting
setState(() {
_connectingToServerUrl = server.serverUrl;
});
try {
// Pre-verify server is still available
final isAvailable = await _networkScanner
.verifyServer(server);
if (!isAvailable) {
throw Exception(
'Server at ${server.ipAddress}:${server.port} is no longer available. '
'Please scan again to find active servers.',
);
}
await connectionProvider.connectToSseServer(
serverUrl: server.serverUrl,
);
await appProvider.initialize();
if (mounted) {
navigator.pop();
}
} catch (e) {
// Clear connecting state on error
if (mounted) {
setState(() {
_connectingToServerUrl = null;
});
// Clean up error message (remove "Exception: " prefix)
String errorMessage = e.toString();
if (errorMessage.startsWith(
'Exception: ',
)) {
errorMessage = errorMessage.substring(
'Exception: '.length,
);
}
if (errorMessage.startsWith(
'Connection failed: Exception: ',
)) {
errorMessage = errorMessage.substring(
'Connection failed: Exception: '.length,
);
} else if (errorMessage.startsWith(
'Connection failed: ',
)) {
errorMessage = errorMessage.substring(
'Connection failed: '.length,
);
}
messenger.showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
}
},
),
);
},
),
),
],
);
}
}

View File

@@ -0,0 +1,155 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart';
import '../models/sse_server_config.dart';
/// Connection Mode Selector Widget
///
/// Allows user to enable/disable SSE Server mode to share device with multiple clients
class ConnectionModeSelector extends StatefulWidget {
const ConnectionModeSelector({super.key});
@override
State<ConnectionModeSelector> createState() => _ConnectionModeSelectorState();
}
class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
List<String> _localIPs = [];
@override
void initState() {
super.initState();
_loadLocalIPs();
}
Future<void> _loadLocalIPs() async {
final Set<String> ipsSet = {};
try {
final interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
ipsSet.add(addr.address);
}
}
}
} catch (e) {
debugPrint('Error getting network interfaces: $e');
}
if (mounted) {
setState(() {
_localIPs = ipsSet.toList();
});
}
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
final connectionProvider = Provider.of<ConnectionProvider>(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Network Sharing',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
// SSE Server Toggle
SwitchListTile(
secondary: const Icon(Icons.share),
title: const Text('Share Device (Server)'),
subtitle: Text(
connectionProvider.isSseServerRunning
? 'Server running on port ${connectionProvider.sseServerConfig.port} - ${connectionProvider.sseClientCount} client(s) connected'
: 'Share BLE device with multiple clients over network',
),
value: connectionProvider.isSseServerRunning,
onChanged: (enabled) async {
if (enabled) {
// Start server with default config (port 12929, no auth)
final config = const SseServerConfig(port: 12929, enabled: true);
try {
await connectionProvider.startSseServer(config);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('SSE server started on port 12929'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start server: $e'),
backgroundColor: Colors.red,
),
);
}
}
} else {
// Stop server
await connectionProvider.stopSseServer();
}
},
),
// Show IP addresses when server is running
if (connectionProvider.isSseServerRunning && _localIPs.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Connect from other devices:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
..._localIPs.map((ip) {
final url = 'http://$ip:${connectionProvider.sseServerConfig.port}';
return ListTile(
dense: true,
leading: const Icon(Icons.wifi, size: 20),
title: Text(
url,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
),
trailing: IconButton(
icon: const Icon(Icons.copy, size: 20),
tooltip: 'Copy URL',
onPressed: () {
Clipboard.setData(ClipboardData(text: url));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Copied $url'),
duration: const Duration(seconds: 1),
),
);
},
),
);
}),
],
],
);
}
}

View File

@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
/// Dialog for adding a new channel
class AddChannelDialog extends StatefulWidget {
final Future<void> Function(String name, String secret) onCreateChannel;
const AddChannelDialog({
super.key,
required this.onCreateChannel,
});
@override
State<AddChannelDialog> createState() => _AddChannelDialogState();
}
class _AddChannelDialogState extends State<AddChannelDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _secretController = TextEditingController();
bool _isCreating = false;
@override
void dispose() {
_nameController.dispose();
_secretController.dispose();
super.dispose();
}
/// Validate that a string contains only ASCII characters
bool _isAscii(String text) {
return text.codeUnits.every((unit) => unit < 128);
}
/// Validate channel name
String? _validateName(String? value) {
final l10n = AppLocalizations.of(context)!;
if (value == null || value.trim().isEmpty) {
return l10n.channelNameRequired;
}
if (value.length > 31) {
return l10n.channelNameTooLong;
}
if (!_isAscii(value)) {
return l10n.invalidAsciiCharacters;
}
return null;
}
/// Validate channel secret
String? _validateSecret(String? value) {
final l10n = AppLocalizations.of(context)!;
if (value == null || value.isEmpty) {
return l10n.channelSecretRequired;
}
if (value.length > 32) {
return l10n.channelSecretTooLong;
}
if (!_isAscii(value)) {
return l10n.invalidAsciiCharacters;
}
return null;
}
/// Handle channel creation
Future<void> _handleCreate() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isCreating = true;
});
try {
final channelName = _nameController.text.trim();
final isHashChannel = channelName.startsWith('#');
// For hash channels, pass empty secret (will be auto-generated)
// For private channels, use the provided secret
final secret = isHashChannel ? '' : _secretController.text;
await widget.onCreateChannel(channelName, secret);
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
// Error is handled by parent
setState(() {
_isCreating = false;
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isHashChannel = _nameController.text.startsWith('#');
return AlertDialog(
title: Text(l10n.addChannel),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Info banner explaining channel types
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.channelTypesInfo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 16),
// Channel Name Field
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.channelName,
hintText: l10n.channelNameHint,
border: const OutlineInputBorder(),
prefixIcon: Icon(
isHashChannel ? Icons.tag : Icons.lock_outline,
color: isHashChannel ? Colors.blue : Colors.orange,
),
),
enabled: !_isCreating,
maxLength: 31,
validator: _validateName,
textInputAction: TextInputAction.next,
onChanged: (_) => setState(() {}), // Rebuild to update icon
),
// Channel Secret Field (only show for private channels)
if (!isHashChannel) ...[
const SizedBox(height: 16),
TextFormField(
controller: _secretController,
decoration: InputDecoration(
labelText: l10n.channelSecret,
hintText: l10n.channelSecretHint,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: !_isCreating,
maxLength: 32,
validator: _validateSecret,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleCreate(),
),
const SizedBox(height: 8),
// Help Text for private channels
Text(
l10n.channelSecretHelp,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
// Help Text for hash channels
if (isHashChannel) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.auto_awesome,
size: 20,
color: Colors.blue,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.hashChannelInfo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
],
),
),
),
actions: [
// Cancel Button
TextButton(
onPressed: _isCreating ? null : () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
// Create Button
FilledButton(
onPressed: _isCreating ? null : _handleCreate,
child: _isCreating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.createChannel),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,452 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/messages_provider.dart';
import '../../providers/app_provider.dart';
import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart';
class DirectMessageSheet extends StatefulWidget {
final Contact contact;
const DirectMessageSheet({super.key, required this.contact});
@override
State<DirectMessageSheet> createState() => _DirectMessageSheetState();
}
class _DirectMessageSheetState extends State<DirectMessageSheet> {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
int _characterCount = 0;
static const int _maxCharacters = 160;
@override
void initState() {
super.initState();
_textController.addListener(_updateCharacterCount);
}
@override
void dispose() {
_textController.dispose();
_focusNode.dispose();
super.dispose();
}
void _updateCharacterCount() {
if (!mounted) return;
setState(() {
_characterCount = _textController.text.length;
});
}
/// Insert current GPS location at cursor position
Future<void> _insertCurrentLocation() async {
try {
// Check location permission
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (!mounted) return;
ToastLogger.error(context, 'Location permission denied');
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (!mounted) return;
ToastLogger.error(context, 'Location permission permanently denied');
return;
}
// Get current position
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
),
);
// Format location text
final locationText =
'📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}';
// Check if adding location would exceed limit
final currentText = _textController.text;
if (currentText.length + locationText.length > _maxCharacters) {
if (!mounted) return;
ToastLogger.error(
context,
'Adding location would exceed 160 character limit',
);
return;
}
// Insert at cursor position or append
final selection = _textController.selection;
final newText = currentText.replaceRange(
selection.start >= 0 ? selection.start : currentText.length,
selection.end >= 0 ? selection.end : currentText.length,
locationText,
);
_textController.text = newText;
// Move cursor to end of inserted text
final newCursorPosition =
(selection.start >= 0 ? selection.start : currentText.length) +
locationText.length;
_textController.selection = TextSelection.fromPosition(
TextPosition(offset: newCursorPosition),
);
if (!mounted) return;
} catch (e) {
if (!mounted) return;
ToastLogger.error(context, 'Failed to get location: $e');
}
}
Future<void> _sendDirectMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
try {
// Create message ID
final messageId = '${DateTime.now().millisecondsSinceEpoch}_dm_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object with recipient public key for retry support
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey:
widget.contact.publicKey, // Store recipient for retry
);
// Add to messages list with "sending" status
// Pass contact for retry logic
messagesProvider.addSentMessage(sentMessage, contact: widget.contact);
// Send direct message to contact (include contact for path logging)
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: widget.contact.publicKey,
text: text,
messageId: messageId, // Pass message ID for tracking
contact: widget.contact,
);
if (!sentSuccessfully) {
// Mark message as failed if sending failed
messagesProvider.markMessageFailed(messageId);
}
_textController.clear();
_focusNode.unfocus();
if (!mounted) return;
Navigator.pop(context); // Close the dialog
} catch (e) {
if (!mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSend(e.toString()),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final appProvider = context.watch<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
final contactLocation = widget.contact.displayLocation;
return Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
Text(
AppLocalizations.of(context)!.directMessage,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
widget.contact.displayName,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 48), // Spacer to keep title centered
],
),
),
// Mini map in simple mode (scrollable content)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 16),
if (isSimpleMode && contactLocation != null) ...[
GestureDetector(
onTap: () {
// Hide keyboard when tapping on map
_focusNode.unfocus();
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outline),
),
clipBehavior: Clip.antiAlias,
child: FlutterMap(
options: MapOptions(
initialCenter: LatLng(
contactLocation.latitude,
contactLocation.longitude,
),
initialZoom: 13.0,
interactionOptions: const InteractionOptions(
flags:
InteractiveFlag.pinchZoom |
InteractiveFlag.drag,
),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
MarkerLayer(
markers: [
Marker(
point: LatLng(
contactLocation.latitude,
contactLocation.longitude,
),
width: 40,
height: 40,
child: Icon(
Icons.location_on,
color: colorScheme.primary,
size: 40,
),
),
],
),
],
),
),
),
const SizedBox(height: 8),
// Location coordinates
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.gps_fixed,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(height: 16),
],
],
),
),
),
// Message input
Container(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
),
child: Column(
children: [
TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: 3,
autofocus: true,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.all(16),
counterText: '', // Hide default counter
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendDirectMessage(),
),
// Always-visible character counter
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'$_characterCount / $_maxCharacters',
style: TextStyle(
fontSize: 12,
color: _characterCount > 155
? Colors.red
: (_characterCount > 140
? Colors.orange
: colorScheme.onSurfaceVariant),
fontWeight: _characterCount > 140
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
const SizedBox(height: 8),
// Location and Send buttons
Row(
children: [
OutlinedButton.icon(
onPressed: _insertCurrentLocation,
icon: const Icon(Icons.my_location, size: 18),
label: Text(AppLocalizations.of(context)!.myLocation),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: BorderSide(color: colorScheme.outline),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _textController.text.trim().isEmpty
? null
: _sendDirectMessage,
icon: const Icon(Icons.send),
label: Text(
AppLocalizations.of(context)!.sendDirectMessage,
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor:
colorScheme.surfaceContainerHighest,
disabledForegroundColor: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,506 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
class RoomLoginSheet extends StatefulWidget {
final Contact contact;
const RoomLoginSheet({super.key, required this.contact});
@override
State<RoomLoginSheet> createState() => _RoomLoginSheetState();
}
class _RoomLoginSheetState extends State<RoomLoginSheet> {
final TextEditingController _passwordController = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _isLoggingIn = false;
bool _obscurePassword = true;
bool _isDisposed = false; // Track disposal state for async callbacks
@override
void initState() {
super.initState();
_loadSavedPassword();
}
@override
void dispose() {
_isDisposed = true;
_passwordController.dispose();
_focusNode.dispose();
super.dispose();
}
/// Load saved password for this room
Future<void> _loadSavedPassword() async {
final prefs = await SharedPreferences.getInstance();
final roomKey = 'room_password_${widget.contact.publicKeyHex}';
final savedPassword = prefs.getString(roomKey);
if (savedPassword != null) {
_passwordController.text = savedPassword;
}
}
/// Save password for this room
Future<void> _savePassword(String password) async {
final prefs = await SharedPreferences.getInstance();
final roomKey = 'room_password_${widget.contact.publicKeyHex}';
await prefs.setString(roomKey, password);
}
Future<void> _loginToRoom() async {
final password = _passwordController.text.trim();
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (password.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.pleaseEnterPassword),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
return;
}
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.deviceNotConnected),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
return;
}
setState(() {
_isLoggingIn = true;
});
// 🕐 CLOCK DRIFT CHECK: Get device time to detect synchronization issues
debugPrint(
'🕐 [RoomLogin] Checking for clock drift between app and radio...',
);
try {
await connectionProvider.getDeviceTime();
// Give time for response to be logged
await Future.delayed(const Duration(milliseconds: 300));
} catch (e) {
debugPrint('⚠️ [RoomLogin] Failed to get device time: $e');
// Don't fail login - this is just a diagnostic check
}
// 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device
debugPrint(
'🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...',
);
debugPrint(
' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
// Check if the room exists in our local contacts
bool roomExists = contactsProvider.rooms.any(
(room) => room.publicKeyHex == widget.contact.publicKeyHex,
);
debugPrint(
' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}',
);
if (!roomExists) {
debugPrint(
'⚠️ [RoomLogin] Room not in local contacts - syncing with device...',
);
try {
// Sync contacts from device
await connectionProvider.getContacts();
// Give time for contacts to be processed
await Future.delayed(const Duration(milliseconds: 800));
// Check again after sync
roomExists = contactsProvider.rooms.any(
(room) => room.publicKeyHex == widget.contact.publicKeyHex,
);
debugPrint(
' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}',
);
if (!roomExists) {
// Room still doesn't exist on the device - try to add it manually
debugPrint('❌ [RoomLogin] Room still not found after sync');
debugPrint(
'🔧 [RoomLogin] Attempting to add room contact to companion radio...',
);
try {
// Manually add the room contact to the radio's flash storage
await connectionProvider.addOrUpdateContact(widget.contact);
debugPrint(
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',
);
debugPrint(' Waiting 500ms for radio to save to flash...');
// Give the radio time to save the contact to flash
await Future.delayed(const Duration(milliseconds: 500));
debugPrint(
'✅ [RoomLogin] Room contact should now be available - proceeding with login',
);
} catch (e) {
debugPrint('❌ [RoomLogin] Failed to add room contact: $e');
if (!mounted) return;
setState(() {
_isLoggingIn = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToAddRoom(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 7),
),
);
// Log available rooms for debugging
final availableRooms = contactsProvider.rooms;
debugPrint(
'📋 [RoomLogin] Available rooms on device (${availableRooms.length}):',
);
for (final room in availableRooms) {
debugPrint(
' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})',
);
}
return;
}
}
debugPrint(
'✅ [RoomLogin] Room contact found after sync - proceeding with login',
);
} catch (e) {
debugPrint('❌ [RoomLogin] Contact sync failed: $e');
if (!mounted) return;
setState(() {
_isLoggingIn = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToSyncContacts(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
return;
}
} else {
debugPrint(
'✅ [RoomLogin] Room contact found in local contacts - proceeding with login',
);
}
// Save password before sending
await _savePassword(password);
// Set up login callbacks
Function(Uint8List, int, bool, int)? originalOnSuccess;
Function(Uint8List)? originalOnFail;
originalOnSuccess = connectionProvider.onLoginSuccess;
originalOnFail = connectionProvider.onLoginFail;
connectionProvider
.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
// Restore original callback
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint(
'✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin',
);
debugPrint(
'📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING',
);
debugPrint(
' Messages will be fetched when onMessageWaiting callback is triggered',
);
// Check both _isDisposed flag and mounted to handle race conditions
if (_isDisposed || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.loggedInSuccessfully),
backgroundColor: Theme.of(context).colorScheme.primary,
duration: const Duration(seconds: 3),
),
);
};
connectionProvider.onLoginFail = (publicKeyPrefix) {
// Restore original callback
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('❌ [RoomLogin] Login failed - incorrect password');
// Check both _isDisposed flag and mounted to handle race conditions
if (_isDisposed || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.loginFailed),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 3),
),
);
};
try {
// Send login request to room
await connectionProvider.loginToRoom(
roomPublicKey: widget.contact.publicKey,
password: password,
);
_focusNode.unfocus();
if (!mounted) return;
Navigator.pop(context); // Close the dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.loggingIn(widget.contact.displayName),
),
backgroundColor: Theme.of(context).colorScheme.primary,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
// Restore original callbacks on error
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToSendLogin(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) {
setState(() {
_isLoggingIn = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
AppLocalizations.of(context)!.loginToRoom,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
widget.contact.displayName,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 48), // Balance the back button
],
),
),
// Scrollable content area
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
// Info banner
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
color: colorScheme.onPrimaryContainer,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
AppLocalizations.of(context)!.enterPasswordInfo,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
),
),
),
],
),
),
const SizedBox(height: 16),
],
),
),
),
// Password input (fixed at bottom)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _passwordController,
focusNode: _focusNode,
maxLength: 15, // Max password length from protocol
obscureText: _obscurePassword,
autofocus: true,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.password,
labelStyle: TextStyle(
color: colorScheme.onSurfaceVariant,
),
hintText: AppLocalizations.of(context)!.enterRoomPassword,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.all(16),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: colorScheme.onSurfaceVariant,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _loginToRoom(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoggingIn ? null : _loginToRoom,
icon: _isLoggingIn
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: Text(
_isLoggingIn
? AppLocalizations.of(context)!.loggingInDots
: AppLocalizations.of(context)!.login,
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
class SectionHeader extends StatelessWidget {
final String title;
final int count;
final IconData icon;
const SectionHeader({
super.key,
required this.title,
required this.count,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
count.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import '../models/map_drawing.dart';
/// Minimap preview widget for map drawings
/// Renders a small 80x80px preview of a drawing on a map background
class DrawingMinimapPreview extends StatelessWidget {
final MapDrawing drawing;
final Widget? tileLayer;
const DrawingMinimapPreview({
super.key,
required this.drawing,
this.tileLayer,
});
/// Calculate bounds for the drawing to fit in the preview
flutter_map.LatLngBounds _calculateBounds() {
if (drawing is LineDrawing) {
final lineDrawing = drawing as LineDrawing;
if (lineDrawing.points.isEmpty) {
// Fallback to default bounds if no points
return flutter_map.LatLngBounds(
const LatLng(0, 0),
const LatLng(0.01, 0.01),
);
}
// Calculate bounds from points
double minLat = lineDrawing.points.first.latitude;
double maxLat = lineDrawing.points.first.latitude;
double minLon = lineDrawing.points.first.longitude;
double maxLon = lineDrawing.points.first.longitude;
for (final point in lineDrawing.points) {
if (point.latitude < minLat) minLat = point.latitude;
if (point.latitude > maxLat) maxLat = point.latitude;
if (point.longitude < minLon) minLon = point.longitude;
if (point.longitude > maxLon) maxLon = point.longitude;
}
// Add padding (10% on each side)
final latPadding = (maxLat - minLat) * 0.1;
final lonPadding = (maxLon - minLon) * 0.1;
return flutter_map.LatLngBounds(
LatLng(minLat - latPadding, minLon - lonPadding),
LatLng(maxLat + latPadding, maxLon + lonPadding),
);
} else if (drawing is RectangleDrawing) {
final rectDrawing = drawing as RectangleDrawing;
// Add padding (10% on each side)
final latDiff = (rectDrawing.bottomRight.latitude - rectDrawing.topLeft.latitude).abs();
final lonDiff = (rectDrawing.bottomRight.longitude - rectDrawing.topLeft.longitude).abs();
final latPadding = latDiff * 0.1;
final lonPadding = lonDiff * 0.1;
return flutter_map.LatLngBounds(
LatLng(
rectDrawing.topLeft.latitude - latPadding,
rectDrawing.topLeft.longitude - lonPadding,
),
LatLng(
rectDrawing.bottomRight.latitude + latPadding,
rectDrawing.bottomRight.longitude + lonPadding,
),
);
}
// Fallback to default bounds
return flutter_map.LatLngBounds(
const LatLng(0, 0),
const LatLng(0.01, 0.01),
);
}
@override
Widget build(BuildContext context) {
final bounds = _calculateBounds();
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey.shade400,
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
child: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(8),
),
interactionOptions: const flutter_map.InteractionOptions(
flags: flutter_map.InteractiveFlag.none, // Disable all interactions
),
),
children: [
// Use provided tile layer or fallback to gray background
if (tileLayer != null)
tileLayer!
else
Container(color: Colors.grey.shade300),
// Render the drawing
if (drawing is LineDrawing)
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: (drawing as LineDrawing).points,
strokeWidth: 3.0,
color: drawing.color,
),
],
)
else if (drawing is RectangleDrawing)
flutter_map.PolygonLayer(
polygons: [
flutter_map.Polygon(
points: (drawing as RectangleDrawing).corners,
color: drawing.color.withValues(alpha: 0.3),
borderColor: drawing.color,
borderStrokeWidth: 3.0,
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,288 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/contact.dart';
/// Contact list section for the compass dialog.
/// Shows all contacts with location sorted by distance with bearing information.
/// Splits contacts by type: Persons/Team, Repeaters, and Rooms.
class CompassContactList extends StatelessWidget {
final List<Contact> contacts;
final Position? position;
final double? heading;
final Contact? selectedContact;
final bool showContacts;
final bool showRepeaters;
final ValueChanged<Contact?> onContactTap;
const CompassContactList({
super.key,
required this.contacts,
required this.position,
this.heading,
required this.selectedContact,
required this.showContacts,
required this.showRepeaters,
required this.onContactTap,
});
@override
Widget build(BuildContext context) {
if (contacts.isEmpty) {
return const SizedBox.shrink();
}
if (position == null) {
return Text(AppLocalizations.of(context)!.locationUnavailable);
}
final l10n = AppLocalizations.of(context)!;
// Split contacts by type
final persons = <Map<String, dynamic>>[];
final repeaters = <Map<String, dynamic>>[];
final rooms = <Map<String, dynamic>>[];
// Calculate bearings and distances for each contact
for (final contact in contacts) {
if (contact.displayLocation == null) continue;
final bearing = _calculateBearing(
position!.latitude,
position!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
final distance = _calculateDistance(
position!.latitude,
position!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
final item = {
'contact': contact,
'bearing': bearing,
'distance': distance,
};
if (contact.isRepeater) {
repeaters.add(item);
} else if (contact.isRoom) {
rooms.add(item);
} else {
persons.add(item);
}
}
// Sort each list by distance
persons.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
repeaters.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Persons/Team section
if (showContacts && persons.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
child: Text(
l10n.teamMembers,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...persons.map((item) => _buildContactTile(
context,
item,
Icons.groups,
Theme.of(context).colorScheme.primary,
)),
],
// Repeaters section
if (showRepeaters && repeaters.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.repeaters,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...repeaters.map((item) => _buildContactTile(
context,
item,
Icons.router,
Colors.purple,
)),
],
// Rooms section
if (rooms.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.rooms,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...rooms.map((item) => _buildContactTile(
context,
item,
Icons.meeting_room,
Colors.teal,
)),
],
],
);
}
Widget _buildContactTile(
BuildContext context,
Map<String, dynamic> item,
IconData defaultIcon,
Color iconColor,
) {
final contact = item['contact'] as Contact;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selectedContact == contact
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selectedContact == contact
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: ListTile(
dense: true,
leading: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
defaultIcon,
color: iconColor,
size: 24,
),
title: Text(contact.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
],
),
onTap: () {
if (selectedContact == contact) {
// Deselect if already selected
onContactTap(null);
} else {
// Select this contact
onContactTap(contact);
}
},
),
);
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(
double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
// Calculate distance between two points (in meters)
double _calculateDistance(
double lat1, double lon1, double lat2, double lon2) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
String _bearingToCardinal(double bearing) {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final index = ((bearing + 22.5) / 45).floor() % 8;
return directions[index];
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
String _formatRelativeBearing(double bearing, double heading, BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// Calculate relative bearing (how much to turn from current heading)
double relative = bearing - heading;
// Normalize to -180 to +180
while (relative > 180) {
relative -= 360;
}
while (relative < -180) {
relative += 360;
}
final absRelative = relative.abs().round();
if (absRelative < 10) {
return l10n.ahead;
} else if (relative > 0) {
return l10n.degreesRight(absRelative);
} else {
return l10n.degreesLeft(absRelative);
}
}
}

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../l10n/app_localizations.dart';
import '../../../providers/map_provider.dart';
/// Filter controls for the compass dialog.
/// Allows filtering of contacts and SAR marker types.
class CompassFilters extends StatefulWidget {
final bool showContacts;
final bool showRepeaters;
final bool showFoundPerson;
final bool showFire;
final bool showStagingArea;
final ValueChanged<bool> onShowContactsChanged;
final ValueChanged<bool> onShowRepeatersChanged;
final ValueChanged<bool> onShowFoundPersonChanged;
final ValueChanged<bool> onShowFireChanged;
final ValueChanged<bool> onShowStagingAreaChanged;
final VoidCallback onShowAll;
const CompassFilters({
super.key,
required this.showContacts,
required this.showRepeaters,
required this.showFoundPerson,
required this.showFire,
required this.showStagingArea,
required this.onShowContactsChanged,
required this.onShowRepeatersChanged,
required this.onShowFoundPersonChanged,
required this.onShowFireChanged,
required this.onShowStagingAreaChanged,
required this.onShowAll,
});
@override
State<CompassFilters> createState() => _CompassFiltersState();
}
class _CompassFiltersState extends State<CompassFilters> {
void _showFilterDialog() {
final l10n = AppLocalizations.of(context)!;
final mapProvider = Provider.of<MapProvider>(context, listen: false);
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Row(
children: [
const Icon(Icons.filter_list, size: 20),
const SizedBox(width: 8),
Text(l10n.filterMarkers),
],
),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Trail visibility toggle
_CompactFilterItem(
icon: Icons.timeline,
color: Colors.blue,
label: 'Location Trail',
value: mapProvider.isTrailVisible,
onChanged: (value) {
mapProvider.toggleTrailVisibility();
setDialogState(() {});
},
),
const SizedBox(height: 4),
const Divider(height: 8),
const SizedBox(height: 4),
// Contacts filter
_CompactFilterItem(
icon: Icons.person,
color: Theme.of(context).colorScheme.primary,
label: l10n.contactsFilter,
value: widget.showContacts,
onChanged: (value) {
widget.onShowContactsChanged(value);
setDialogState(() {});
},
),
const SizedBox(height: 4),
// Repeaters filter
_CompactFilterItem(
icon: Icons.router,
color: Colors.purple,
label: l10n.repeatersFilter,
value: widget.showRepeaters,
onChanged: (value) {
widget.onShowRepeatersChanged(value);
setDialogState(() {});
},
),
const SizedBox(height: 4),
const Divider(height: 8),
const SizedBox(height: 4),
// SAR Markers section
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8, top: 4),
child: Text(
l10n.sarMarkers,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
_CompactFilterItem(
icon: Icons.person_pin,
color: Colors.green,
label: l10n.foundPerson,
value: widget.showFoundPerson,
onChanged: (value) {
widget.onShowFoundPersonChanged(value);
setDialogState(() {});
},
),
const SizedBox(height: 4),
_CompactFilterItem(
icon: Icons.local_fire_department,
color: Colors.red,
label: l10n.fire,
value: widget.showFire,
onChanged: (value) {
widget.onShowFireChanged(value);
setDialogState(() {});
},
),
const SizedBox(height: 4),
_CompactFilterItem(
icon: Icons.home_work,
color: Colors.orange,
label: l10n.stagingArea,
value: widget.showStagingArea,
onChanged: (value) {
widget.onShowStagingAreaChanged(value);
setDialogState(() {});
},
),
],
),
actions: [
TextButton(
onPressed: () {
widget.onShowAll();
setDialogState(() {});
},
child: Text(l10n.showAll),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.close),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return IconButton(
icon: const Icon(Icons.filter_list),
tooltip: l10n.filterMarkersTooltip,
onPressed: () => _showFilterDialog(),
);
}
}
/// Compact filter item widget
class _CompactFilterItem extends StatelessWidget {
final IconData icon;
final Color color;
final String label;
final bool value;
final ValueChanged<bool> onChanged;
const _CompactFilterItem({
required this.icon,
required this.color,
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => onChanged(!value),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Checkbox(
value: value,
onChanged: (val) => onChanged(val ?? false),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,609 @@
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/contact.dart';
import '../../../models/sar_marker.dart';
/// Header component for the compass dialog showing compass rose,
/// heading, elevation, accuracy, and current location in multiple formats.
class CompassHeader extends StatelessWidget {
final double? heading;
final Position? position;
final bool hasHeading;
final Position? currentPosition;
final List<Contact> contacts;
final List<SarMarker> sarMarkers;
final double zoomLevel;
final double previousScale;
final ValueChanged<double> onZoomUpdate;
final VoidCallback onScaleStart;
final VoidCallback onScaleEnd;
const CompassHeader({
super.key,
required this.heading,
required this.position,
required this.hasHeading,
required this.currentPosition,
required this.contacts,
required this.sarMarkers,
required this.zoomLevel,
required this.previousScale,
required this.onZoomUpdate,
required this.onScaleStart,
required this.onScaleEnd,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Heading and Elevation info
_buildInfoRow(context, heading, position),
const SizedBox(height: 12),
// Current location in multiple formats
if (position != null) _LocationFormatToggle(position: position),
const SizedBox(height: 12),
// Large compass with zoom controls
GestureDetector(
onScaleStart: (details) {
onScaleStart();
},
onScaleUpdate: (details) {
onZoomUpdate(details.scale);
},
onScaleEnd: (details) {
onScaleEnd();
},
child: SizedBox(
width: 300,
height: 300,
child: _DetailedCompassPainter(
heading: heading ?? 0,
hasHeading: hasHeading,
currentPosition: currentPosition,
contacts: contacts,
sarMarkers: sarMarkers,
zoomLevel: zoomLevel,
),
),
),
],
);
}
Widget _buildInfoRow(BuildContext context, double? heading, Position? position) {
final l10n = AppLocalizations.of(context)!;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildInfoCard(
context,
l10n.heading,
heading != null ? '${heading.round()}°' : '--',
Icons.explore,
),
_buildInfoCard(
context,
l10n.elevation,
position?.altitude != null
? '${position!.altitude.round()}m'
: '--',
Icons.terrain,
),
_buildInfoCard(
context,
l10n.accuracy,
position?.accuracy != null
? '±${position!.accuracy.round()}m'
: '--',
Icons.gps_fixed,
),
],
);
}
Widget _buildInfoCard(
BuildContext context, String label, String value, IconData icon) {
return Column(
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
/// Detailed Compass Painter with contacts
class _DetailedCompassPainter extends StatelessWidget {
final double heading;
final bool hasHeading;
final Position? currentPosition;
final List<Contact> contacts;
final List<SarMarker> sarMarkers;
final double zoomLevel;
const _DetailedCompassPainter({
required this.heading,
required this.hasHeading,
required this.currentPosition,
required this.contacts,
required this.sarMarkers,
this.zoomLevel = 1.0,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _LargeCompassPainter(
heading: heading,
hasHeading: hasHeading,
currentPosition: currentPosition,
contacts: contacts,
sarMarkers: sarMarkers,
zoomLevel: zoomLevel,
),
child: Container(),
);
}
}
class _LargeCompassPainter extends CustomPainter {
final double heading;
final bool hasHeading;
final Position? currentPosition;
final List<Contact> contacts;
final List<SarMarker> sarMarkers;
final double zoomLevel;
_LargeCompassPainter({
required this.heading,
required this.hasHeading,
required this.currentPosition,
required this.contacts,
required this.sarMarkers,
this.zoomLevel = 1.0,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// Draw outer circle
final circlePaint = Paint()
..color = Colors.grey.withValues(alpha: 0.2)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(center, radius, circlePaint);
// Draw degree markers
for (int i = 0; i < 360; i += 10) {
final angle = i * pi / 180 - pi / 2 + heading * pi / 180;
final isCardinal = i % 90 == 0;
final isMajor = i % 30 == 0;
final startRadius = isCardinal ? radius - 25 : (isMajor ? radius - 15 : radius - 10);
final start = Offset(
center.dx + startRadius * cos(angle),
center.dy + startRadius * sin(angle),
);
final end = Offset(
center.dx + radius * cos(angle),
center.dy + radius * sin(angle),
);
final markerPaint = Paint()
..color = isCardinal ? Colors.red : Colors.grey
..strokeWidth = isCardinal ? 3 : (isMajor ? 2 : 1);
canvas.drawLine(start, end, markerPaint);
}
// Draw cardinal directions
final textPainter = TextPainter(textDirection: TextDirection.ltr);
final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) {
final angle = i * pi / 2 - pi / 2 + heading * pi / 180;
final x = center.dx + (radius - 35) * cos(angle);
final y = center.dy + (radius - 35) * sin(angle);
textPainter.text = TextSpan(
text: directions[i],
style: TextStyle(
color: i == 0 ? Colors.red : Colors.grey.shade700,
fontSize: 24,
fontWeight: FontWeight.bold,
),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
);
}
// Draw contacts as dots relative to distance, scaled by zoom level
if (currentPosition != null && contacts.isNotEmpty) {
// Calculate distances for all contacts
final contactsWithDistance = contacts
.where((c) => c.displayLocation != null)
.map((contact) {
final bearing = _calculateBearing(
currentPosition!.latitude,
currentPosition!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
final distance = _calculateDistance(
currentPosition!.latitude,
currentPosition!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
return {'contact': contact, 'bearing': bearing, 'distance': distance};
}).toList();
if (contactsWithDistance.isEmpty) return;
// Base distance for zoom level 1.0 (in meters)
// At 1x zoom, contacts within 1km appear inside the compass
final baseDistance = 1000.0 / zoomLevel;
for (final item in contactsWithDistance) {
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
// Adjust bearing relative to current heading
final relativeBearing = (bearing - heading + 360) % 360;
final angle = relativeBearing * pi / 180 - pi / 2;
// Calculate normalized distance (0 to 1, where 1 is at the rim)
// Apply zoom level: higher zoom = contacts appear closer
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
// Calculate contact position radius (from center to rim based on distance)
final contactRadius = radius * normalizedDistance * 0.85; // 0.85 to keep inside rim
// Position of contact dot
final dotX = center.dx + contactRadius * cos(angle);
final dotY = center.dy + contactRadius * sin(angle);
// Draw line from center to contact
final linePaint = Paint()
..color = Colors.lightBlue.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawLine(
center,
Offset(dotX, dotY),
linePaint,
);
// Draw contact dot (size varies with zoom)
final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0);
final dotPaint = Paint()
..color = Colors.lightBlue
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint);
// Draw darker shade border (same color family)
final borderPaint = Paint()
..color = Colors.blue.shade800
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint);
// Draw distance label near the contact (only if not too crowded)
if (zoomLevel >= 0.75) {
final distanceText = _formatDistance(distance);
final labelOffset = dotSize + 12;
final labelX = center.dx + (contactRadius + labelOffset) * cos(angle);
final labelY = center.dy + (contactRadius + labelOffset) * sin(angle);
textPainter.text = TextSpan(
text: distanceText,
style: const TextStyle(
color: Colors.lightBlue,
fontSize: 9,
fontWeight: FontWeight.bold,
),
);
textPainter.layout();
// Draw background for readability
final bgRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(labelX, labelY),
width: textPainter.width + 4,
height: textPainter.height + 2,
),
const Radius.circular(3),
);
final bgPaint = Paint()
..color = Colors.white.withValues(alpha: 0.9)
..style = PaintingStyle.fill;
canvas.drawRRect(bgRect, bgPaint);
textPainter.paint(
canvas,
Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2),
);
}
}
}
// Draw SAR markers as colored dots relative to distance, scaled by zoom level
if (currentPosition != null && sarMarkers.isNotEmpty) {
// Calculate distances for all SAR markers
final markersWithDistance = sarMarkers.map((marker) {
final bearing = _calculateBearing(
currentPosition!.latitude,
currentPosition!.longitude,
marker.location.latitude,
marker.location.longitude,
);
final distance = _calculateDistance(
currentPosition!.latitude,
currentPosition!.longitude,
marker.location.latitude,
marker.location.longitude,
);
return {'marker': marker, 'bearing': bearing, 'distance': distance};
}).toList();
// Base distance for zoom level 1.0 (in meters)
final baseDistance = 1000.0 / zoomLevel;
for (final item in markersWithDistance) {
final marker = item['marker'] as SarMarker;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
// Adjust bearing relative to current heading
final relativeBearing = (bearing - heading + 360) % 360;
final angle = relativeBearing * pi / 180 - pi / 2;
// Calculate normalized distance (0 to 1, where 1 is at the rim)
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
// Calculate marker position radius (from center to rim based on distance)
final markerRadius = radius * normalizedDistance * 0.85;
// Position of marker dot
final dotX = center.dx + markerRadius * cos(angle);
final dotY = center.dy + markerRadius * sin(angle);
// Determine color based on SAR marker type
Color markerColor;
Color borderColor;
switch (marker.type) {
case SarMarkerType.foundPerson:
markerColor = Colors.green;
borderColor = Colors.green.shade900;
break;
case SarMarkerType.fire:
markerColor = Colors.red;
borderColor = Colors.red.shade900;
break;
case SarMarkerType.stagingArea:
markerColor = Colors.orange;
borderColor = Colors.orange.shade900;
break;
case SarMarkerType.object:
markerColor = Colors.purple;
borderColor = Colors.purple.shade900;
break;
case SarMarkerType.unknown:
markerColor = Colors.grey;
borderColor = Colors.grey.shade900;
break;
}
// Draw line from center to SAR marker
final linePaint = Paint()
..color = markerColor.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawLine(
center,
Offset(dotX, dotY),
linePaint,
);
// Draw SAR marker dot (slightly larger than contacts)
final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0);
final dotPaint = Paint()
..color = markerColor
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint);
// Draw darker shade border (same color family)
final borderPaint = Paint()
..color = borderColor
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint);
// Draw distance label near the SAR marker
if (zoomLevel >= 0.75) {
final distanceText = _formatDistance(distance);
final labelOffset = dotSize + 14;
final labelX = center.dx + (markerRadius + labelOffset) * cos(angle);
final labelY = center.dy + (markerRadius + labelOffset) * sin(angle);
textPainter.text = TextSpan(
text: distanceText,
style: TextStyle(
color: markerColor,
fontSize: 10,
fontWeight: FontWeight.bold,
),
);
textPainter.layout();
// Draw background for readability
final bgRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(labelX, labelY),
width: textPainter.width + 4,
height: textPainter.height + 2,
),
const Radius.circular(3),
);
final bgPaint = Paint()
..color = Colors.white.withValues(alpha: 0.9)
..style = PaintingStyle.fill;
canvas.drawRRect(bgRect, bgPaint);
textPainter.paint(
canvas,
Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2),
);
}
}
}
// Draw center heading indicator (fixed pointing up)
final indicatorPaint = Paint()
..color = hasHeading ? Colors.red : Colors.grey
..style = PaintingStyle.fill;
final path = ui.Path()
..moveTo(center.dx, center.dy - 40)
..lineTo(center.dx - 10, center.dy + 10)
..lineTo(center.dx + 10, center.dy + 10)
..close();
canvas.drawPath(path, indicatorPaint);
}
double _calculateBearing(
double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
double _calculateDistance(
double lat1, double lon1, double lat2, double lon2) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// Location format toggle widget
class _LocationFormatToggle extends StatefulWidget {
final Position? position;
const _LocationFormatToggle({required this.position});
@override
State<_LocationFormatToggle> createState() => _LocationFormatToggleState();
}
class _LocationFormatToggleState extends State<_LocationFormatToggle> {
bool _showDMS = false;
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';
}
@override
Widget build(BuildContext context) {
final position = widget.position;
if (position == null) {
return const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context)!;
final String displayText;
if (_showDMS) {
displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
} else {
displayText = l10n.latLonFormat(
position.latitude.toStringAsFixed(5),
position.longitude.toStringAsFixed(5),
);
}
return GestureDetector(
onTap: () {
setState(() {
_showDMS = !_showDMS;
});
},
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
displayText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
),
),
),
);
}
}

View File

@@ -0,0 +1,236 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/sar_marker.dart';
/// SAR marker list section for the compass dialog.
/// Shows all filtered SAR markers sorted by distance with bearing information.
class CompassSarList extends StatelessWidget {
final List<SarMarker> sarMarkers;
final Position? position;
final double? heading;
final SarMarker? selectedSarMarker;
final ValueChanged<SarMarker?> onSarMarkerTap;
const CompassSarList({
super.key,
required this.sarMarkers,
required this.position,
this.heading,
required this.selectedSarMarker,
required this.onSarMarkerTap,
});
@override
Widget build(BuildContext context) {
if (sarMarkers.isEmpty) {
return const SizedBox.shrink();
}
if (position == null) {
return Text(AppLocalizations.of(context)!.locationUnavailable);
}
// Calculate bearings and distances for SAR markers
final markersWithBearing = sarMarkers.map((marker) {
final bearing = _calculateBearing(
position!.latitude,
position!.longitude,
marker.location.latitude,
marker.location.longitude,
);
final distance = _calculateDistance(
position!.latitude,
position!.longitude,
marker.location.latitude,
marker.location.longitude,
);
return {
'marker': marker,
'bearing': bearing,
'distance': distance,
};
}).toList();
// Sort by distance
markersWithBearing.sort((a, b) =>
(a['distance'] as double).compareTo(b['distance'] as double));
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8),
child: Text(
l10n.sarMarkers,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...markersWithBearing.map((item) {
final marker = item['marker'] as SarMarker;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
// Determine color and icon based on marker type
Color markerColor;
IconData markerIcon;
switch (marker.type) {
case SarMarkerType.foundPerson:
markerColor = Colors.green;
markerIcon = Icons.person_pin;
break;
case SarMarkerType.fire:
markerColor = Colors.red;
markerIcon = Icons.local_fire_department;
break;
case SarMarkerType.stagingArea:
markerColor = Colors.orange;
markerIcon = Icons.home_work;
break;
case SarMarkerType.object:
markerColor = Colors.purple;
markerIcon = Icons.inventory_2;
break;
case SarMarkerType.unknown:
markerColor = Colors.grey;
markerIcon = Icons.help_outline;
break;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selectedSarMarker == marker
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selectedSarMarker == marker
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: ListTile(
dense: true,
leading: Icon(
markerIcon,
color: markerColor,
size: 24,
),
title: Text(marker.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}${marker.timeAgo}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
],
),
onTap: () {
if (selectedSarMarker == marker) {
// Deselect if already selected
onSarMarkerTap(null);
} else {
// Select this marker
onSarMarkerTap(marker);
}
},
),
);
}),
],
);
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(
double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
// Calculate distance between two points (in meters)
double _calculateDistance(
double lat1, double lon1, double lat2, double lon2) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
String _bearingToCardinal(double bearing) {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final index = ((bearing + 22.5) / 45).floor() % 8;
return directions[index];
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
String _formatRelativeBearing(double bearing, double heading, BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// Calculate relative bearing (how much to turn from current heading)
double relative = bearing - heading;
// Normalize to -180 to +180
while (relative > 180) {
relative -= 360;
}
while (relative < -180) {
relative += 360;
}
final absRelative = relative.abs().round();
if (absRelative < 10) {
return l10n.ahead;
} else if (relative > 0) {
return l10n.degreesRight(absRelative);
} else {
return l10n.degreesLeft(absRelative);
}
}
}

View File

@@ -0,0 +1,107 @@
import 'dart:math';
import 'package:flutter/material.dart';
class CompassWidget extends StatelessWidget {
final double heading;
final bool hasHeading;
const CompassWidget({
super.key,
required this.heading,
required this.hasHeading,
});
@override
Widget build(BuildContext context) {
return Card(
child: Container(
width: 56,
height: 56,
padding: const EdgeInsets.all(8),
child: Stack(
alignment: Alignment.center,
children: [
// Compass rose background - rotates to show true north at top
Transform.rotate(
angle: heading * pi / 180,
child: CustomPaint(
size: const Size(40, 40),
painter: _CompassRosePainter(),
),
),
// Fixed needle pointing up (since map rotates)
Icon(
Icons.navigation,
color: hasHeading ? Colors.red : Colors.grey,
size: 28,
),
// Heading text
Positioned(
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: Text(
hasHeading ? '${heading.round()}°' : '--',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}
class _CompassRosePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.grey.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1;
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// Draw circle
canvas.drawCircle(center, radius, paint);
// Draw cardinal direction markers
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
);
final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) {
final angle = i * pi / 2 - pi / 2; // Start from North (top)
final x = center.dx + radius * 0.7 * cos(angle);
final y = center.dy + radius * 0.7 * sin(angle);
textPainter.text = TextSpan(
text: directions[i],
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 10,
fontWeight: FontWeight.bold,
),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,765 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:latlong2/latlong.dart';
import '../../models/contact.dart';
import '../../models/sar_marker.dart';
import '../common/location_display.dart';
import 'compass/compass_header.dart';
import 'compass/compass_filters.dart';
import 'compass/compass_sar_list.dart';
import 'compass/compass_contact_list.dart';
import '../../l10n/app_localizations.dart';
enum HeadingAccuracySeverity { low, medium, high }
class HeadingAccuracyInfo {
final bool isAccurate;
final String? warning;
final HeadingAccuracySeverity severity;
HeadingAccuracyInfo({
required this.isAccurate,
this.warning,
this.severity = HeadingAccuracySeverity.low,
});
}
class DetailedCompassDialog extends StatefulWidget {
final Position? initialPosition;
final double? initialHeading;
final List<Contact> contacts;
final List<SarMarker> sarMarkers;
final Contact? preSelectedContact;
final SarMarker? preSelectedSarMarker;
const DetailedCompassDialog({
super.key,
required this.initialPosition,
required this.initialHeading,
required this.contacts,
required this.sarMarkers,
this.preSelectedContact,
this.preSelectedSarMarker,
});
@override
State<DetailedCompassDialog> createState() => _DetailedCompassDialogState();
}
class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
double? _currentHeading;
double? _compassAccuracy; // Compass accuracy in degrees
Position? _currentPosition;
StreamSubscription<CompassEvent>? _compassSubscription;
StreamSubscription<Position>? _positionSubscription;
double _zoomLevel =
1.0; // 1.0 = default, 0.5 = zoomed out 2x, 2.0 = zoomed in 2x
double _previousScale = 1.0; // Track previous scale for smoother zooming
static const double _minZoom = 0.25;
static const double _maxZoom = 4.0;
static const double _zoomSensitivity =
0.5; // Lower = less sensitive (0.5 = half speed)
// Visibility toggles
bool _showContacts = true;
bool _showRepeaters = false; // Hide repeaters by default
bool _showFoundPerson = true;
bool _showFire = true;
bool _showStagingArea = true;
// Selected item for isolation
Contact? _selectedContact;
SarMarker? _selectedSarMarker;
@override
void initState() {
super.initState();
_currentHeading = widget.initialHeading;
_currentPosition = widget.initialPosition;
_selectedContact = widget.preSelectedContact;
_selectedSarMarker = widget.preSelectedSarMarker;
// Auto-zoom if item is preselected
if (_selectedContact != null || _selectedSarMarker != null) {
// Use post-frame callback to ensure position is set
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoZoomForSelection();
});
}
// Subscribe to compass updates
final compassStream = FlutterCompass.events;
if (compassStream != null) {
_compassSubscription = compassStream.listen((event) {
if (mounted && event.heading != null) {
setState(() {
_currentHeading = event.heading;
_compassAccuracy = event.accuracy;
});
}
});
}
// Subscribe to position updates
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 1,
),
).listen((position) {
if (mounted) {
setState(() {
_currentPosition = position;
});
}
});
}
@override
void dispose() {
_compassSubscription?.cancel();
_positionSubscription?.cancel();
super.dispose();
}
// Get current heading (prefer compass over GPS)
double? get currentHeading {
if (_currentHeading != null) return _currentHeading;
if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) {
return _currentPosition!.heading;
}
return null;
}
// Check heading accuracy and return warning information
HeadingAccuracyInfo get headingAccuracyInfo {
// Using compass
if (_currentHeading != null) {
if (_compassAccuracy == null) {
return HeadingAccuracyInfo(
isAccurate: false,
warning: 'Compass accuracy unknown',
severity: HeadingAccuracySeverity.low,
);
} else if (_compassAccuracy! > 30) {
return HeadingAccuracyInfo(
isAccurate: false,
warning:
'Low compass accuracy (±${_compassAccuracy!.round()}°). Calibrate device.',
severity: HeadingAccuracySeverity.high,
);
} else if (_compassAccuracy! > 15) {
return HeadingAccuracyInfo(
isAccurate: true,
warning: 'Moderate compass accuracy (±${_compassAccuracy!.round()}°)',
severity: HeadingAccuracySeverity.medium,
);
}
return HeadingAccuracyInfo(isAccurate: true);
}
// Using GPS heading
if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) {
final headingAccuracy = _currentPosition!.headingAccuracy;
if (headingAccuracy > 0) {
if (headingAccuracy > 30) {
return HeadingAccuracyInfo(
isAccurate: false,
warning:
'Low GPS heading accuracy (±${headingAccuracy.round()}°). Move faster or use compass.',
severity: HeadingAccuracySeverity.high,
);
} else if (headingAccuracy > 15) {
return HeadingAccuracyInfo(
isAccurate: true,
warning:
'Moderate GPS heading accuracy (±${headingAccuracy.round()}°)',
severity: HeadingAccuracySeverity.medium,
);
}
return HeadingAccuracyInfo(isAccurate: true);
}
// GPS heading available but no accuracy info
return HeadingAccuracyInfo(
isAccurate: true,
warning: 'Using GPS heading (accuracy unknown)',
severity: HeadingAccuracySeverity.low,
);
}
// No heading available
return HeadingAccuracyInfo(
isAccurate: false,
warning: 'No heading available',
severity: HeadingAccuracySeverity.high,
);
}
// Filter SAR markers based on visibility settings
List<SarMarker> _getFilteredSarMarkers() {
return widget.sarMarkers.where((marker) {
switch (marker.type) {
case SarMarkerType.foundPerson:
return _showFoundPerson;
case SarMarkerType.fire:
return _showFire;
case SarMarkerType.stagingArea:
return _showStagingArea;
case SarMarkerType.object:
return true; // Always show object markers (add filter if needed)
case SarMarkerType.unknown:
return true; // Always show unknown markers
}
}).toList();
}
void _handleZoomUpdate(double scale) {
setState(() {
// Calculate scale delta from previous scale
final scaleDelta = scale - _previousScale;
// Apply sensitivity factor to make it more coarse
final adjustedDelta = scaleDelta * _zoomSensitivity;
// Apply the delta to current zoom level
_zoomLevel = (_zoomLevel * (1.0 + adjustedDelta)).clamp(
_minZoom,
_maxZoom,
);
// Update previous scale
_previousScale = scale;
});
}
void _handleScaleStart() {
_previousScale = 1.0;
}
void _handleScaleEnd() {
_previousScale = 1.0;
}
// Calculate appropriate zoom level for selected item
void _autoZoomForSelection() {
if (_currentPosition == null) return;
double? targetDistance;
if (_selectedContact != null && _selectedContact!.displayLocation != null) {
targetDistance = _calculateDistance(
_currentPosition!.latitude,
_currentPosition!.longitude,
_selectedContact!.displayLocation!.latitude,
_selectedContact!.displayLocation!.longitude,
);
} else if (_selectedSarMarker != null) {
targetDistance = _calculateDistance(
_currentPosition!.latitude,
_currentPosition!.longitude,
_selectedSarMarker!.location.latitude,
_selectedSarMarker!.location.longitude,
);
}
if (targetDistance != null) {
// Calculate zoom level to fit target within 70% of compass radius
// Base distance at 1x zoom is 1000m
// We want target at 70% of radius, so: targetDistance / zoomLevel = 700m
final targetZoom = (targetDistance / 700.0).clamp(_minZoom, _maxZoom);
setState(() {
_zoomLevel = targetZoom;
});
}
}
@override
Widget build(BuildContext context) {
final heading = currentHeading;
final position = _currentPosition;
final accuracyInfo = headingAccuracyInfo;
return Column(
children: [
// Header with back button
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
Text(
AppLocalizations.of(context)!.compass,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
AppLocalizations.of(context)!.navigationAndContacts,
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
),
CompassFilters(
showContacts: _showContacts,
showRepeaters: _showRepeaters,
showFoundPerson: _showFoundPerson,
showFire: _showFire,
showStagingArea: _showStagingArea,
onShowContactsChanged: (value) {
setState(() {
_showContacts = value;
});
},
onShowRepeatersChanged: (value) {
setState(() {
_showRepeaters = value;
});
},
onShowFoundPersonChanged: (value) {
setState(() {
_showFoundPerson = value;
});
},
onShowFireChanged: (value) {
setState(() {
_showFire = value;
});
},
onShowStagingAreaChanged: (value) {
setState(() {
_showStagingArea = value;
});
},
onShowAll: () {
setState(() {
_showContacts = true;
_showRepeaters = true;
_showFoundPerson = true;
_showFire = true;
_showStagingArea = true;
});
},
),
],
),
),
// Heading accuracy warning banner
if (accuracyInfo.warning != null)
_buildAccuracyWarning(context, accuracyInfo),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Compass header with info and location formats
CompassHeader(
heading: heading,
position: position,
hasHeading: heading != null,
currentPosition: position,
contacts: _selectedContact != null
? [_selectedContact!]
: (_selectedSarMarker != null
? []
: widget.contacts
.where(
(c) =>
(_showContacts &&
!c.isRepeater &&
!c.isRoom) ||
(_showRepeaters && c.isRepeater),
)
.toList()),
sarMarkers: _selectedSarMarker != null
? [_selectedSarMarker!]
: (_selectedContact != null
? []
: _getFilteredSarMarkers()),
zoomLevel: _zoomLevel,
previousScale: _previousScale,
onZoomUpdate: _handleZoomUpdate,
onScaleStart: _handleScaleStart,
onScaleEnd: _handleScaleEnd,
),
const SizedBox(height: 12),
// Selected item detail view
if (_selectedContact != null || _selectedSarMarker != null)
_buildSelectedItemDetail(context, heading, position),
const SizedBox(height: 12),
// Contacts list
if (widget.contacts.isNotEmpty)
CompassContactList(
contacts: widget.contacts,
position: position,
heading: heading,
selectedContact: _selectedContact,
showContacts: _showContacts,
showRepeaters: _showRepeaters,
onContactTap: (contact) {
setState(() {
_selectedContact = contact;
if (contact != null) {
_selectedSarMarker = null;
}
});
_autoZoomForSelection();
},
),
// SAR Markers list
if (_getFilteredSarMarkers().isNotEmpty)
CompassSarList(
sarMarkers: _getFilteredSarMarkers(),
position: position,
heading: heading,
selectedSarMarker: _selectedSarMarker,
onSarMarkerTap: (marker) {
setState(() {
_selectedSarMarker = marker;
if (marker != null) {
_selectedContact = null;
}
});
_autoZoomForSelection();
},
),
],
),
),
),
),
],
);
}
Widget _buildAccuracyWarning(BuildContext context, HeadingAccuracyInfo info) {
Color backgroundColor;
Color iconColor;
IconData icon;
switch (info.severity) {
case HeadingAccuracySeverity.high:
backgroundColor = Colors.red.shade100;
iconColor = Colors.red.shade700;
icon = Icons.error_outline;
break;
case HeadingAccuracySeverity.medium:
backgroundColor = Colors.orange.shade100;
iconColor = Colors.orange.shade700;
icon = Icons.warning_amber_outlined;
break;
case HeadingAccuracySeverity.low:
backgroundColor = Colors.blue.shade100;
iconColor = Colors.blue.shade700;
icon = Icons.info_outline;
break;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: iconColor.withValues(alpha: 0.3), width: 1),
),
child: Row(
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
info.warning!,
style: TextStyle(
color: iconColor,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildSelectedItemDetail(
BuildContext context,
double? heading,
Position? position,
) {
if (position == null) {
return const SizedBox.shrink();
}
String title;
IconData icon;
Color color;
double? bearing;
double? distance;
LatLng? targetLocation;
String? additionalInfo;
if (_selectedContact != null) {
title = _selectedContact!.displayName;
icon = Icons.person;
color = Theme.of(context).colorScheme.primary;
targetLocation = _selectedContact!.displayLocation;
if (targetLocation != null) {
bearing = _calculateBearing(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
distance = _calculateDistance(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
}
// Show voltage/battery if available
if (_selectedContact!.telemetry?.batteryMilliVolts != null) {
final volts = (_selectedContact!.telemetry!.batteryMilliVolts! / 1000)
.toStringAsFixed(3);
final percent = _selectedContact!.telemetry!.batteryPercentage != null
? ' (${_selectedContact!.telemetry!.batteryPercentage!.round()}%)'
: '';
additionalInfo = 'Voltage: ${volts}V$percent';
} else if (_selectedContact!.telemetry?.batteryPercentage != null) {
additionalInfo =
'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%';
}
} else if (_selectedSarMarker != null) {
title = _selectedSarMarker!.displayName;
targetLocation = _selectedSarMarker!.location;
additionalInfo = _selectedSarMarker!.timeAgo;
switch (_selectedSarMarker!.type) {
case SarMarkerType.foundPerson:
icon = Icons.person_pin;
color = Colors.green;
break;
case SarMarkerType.fire:
icon = Icons.local_fire_department;
color = Colors.red;
break;
case SarMarkerType.stagingArea:
icon = Icons.home_work;
color = Colors.orange;
break;
case SarMarkerType.object:
icon = Icons.inventory_2;
color = Colors.purple;
break;
case SarMarkerType.unknown:
icon = Icons.help_outline;
color = Colors.grey;
break;
}
bearing = _calculateBearing(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
distance = _calculateDistance(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
} else {
return const SizedBox.shrink();
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
// Header with icon and title
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child:
_selectedContact != null &&
_selectedContact!.roleEmoji != null
? Text(
_selectedContact!.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(icon, size: 24, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (additionalInfo != null)
Text(
additionalInfo,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
),
],
),
),
// Close button to deselect contact
IconButton(
icon: const Icon(Icons.close, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
setState(() {
_selectedContact = null;
_selectedSarMarker = null;
});
},
),
],
),
if (bearing != null && distance != null) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// Distance and bearing info
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildLargeInfoCard(
context,
AppLocalizations.of(context)!.distance,
_formatDistance(distance),
Icons.straighten,
color,
),
_buildLargeInfoCard(
context,
AppLocalizations.of(context)!.bearing,
'${bearing.round()}°',
Icons.navigation,
color,
),
_buildLargeInfoCard(
context,
AppLocalizations.of(context)!.direction,
_bearingToCardinal(bearing),
Icons.explore,
color,
),
],
),
const SizedBox(height: 8),
// Coordinates with modal
if (targetLocation != null)
LocationDisplay(location: targetLocation),
],
],
),
),
);
}
Widget _buildLargeInfoCard(
BuildContext context,
String label,
String value,
IconData icon,
Color color,
) {
return Column(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x =
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
// Calculate distance between two points (in meters)
double _calculateDistance(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
String _bearingToCardinal(double bearing) {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final index = ((bearing + 22.5) / 45).floor() % 8;
return directions[index];
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
/// Overlay widget that displays controls for download area selection.
/// The actual polygon should be rendered inside FlutterMap's children.
class DownloadAreaOverlay extends StatelessWidget {
final LatLngBounds bounds;
final VoidCallback onConfirm;
final VoidCallback onCancel;
const DownloadAreaOverlay({
super.key,
required this.bounds,
required this.onConfirm,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Control buttons at the top
Positioned(
top: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Download Area Selection',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The blue rectangle shows the area to be downloaded. '
'To change the area, tap Cancel and select download again.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close),
label: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: onConfirm,
icon: const Icon(Icons.check),
label: const Text('Confirm'),
),
),
],
),
],
),
),
),
),
// Area info at the bottom
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Area Bounds',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'N: ${bounds.north.toStringAsFixed(4)}° '
'S: ${bounds.south.toStringAsFixed(4)}°',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'E: ${bounds.east.toStringAsFixed(4)}° '
'W: ${bounds.west.toStringAsFixed(4)}°',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/map_drawing.dart';
import '../../l10n/app_localizations.dart';
/// Widget that renders map drawings as polylines
class DrawingLayer extends StatelessWidget {
final List<MapDrawing> drawings;
final MapDrawing? previewDrawing;
final bool isSimpleMode;
const DrawingLayer({
super.key,
required this.drawings,
this.previewDrawing,
this.isSimpleMode = false,
});
@override
Widget build(BuildContext context) {
final List<Polyline> polylines = [];
// Add completed drawings
for (final drawing in drawings) {
polylines.add(_createPolyline(drawing, isPreview: false));
}
// Add preview drawing (if any)
if (previewDrawing != null) {
polylines.add(_createPolyline(previewDrawing!, isPreview: true));
}
return PolylineLayer(polylines: polylines);
}
/// Create a polyline from a drawing
Polyline _createPolyline(MapDrawing drawing, {required bool isPreview}) {
final points = _getPoints(drawing);
// Different styles for different drawing sources
final double opacity;
final double strokeWidth;
if (isPreview) {
// Preview drawing (currently being drawn)
opacity = 0.6;
strokeWidth = 4.0;
} else if (drawing.isReceived) {
// Received drawing from another node
// In simple mode: solid (opacity 1.0), in normal mode: translucent (0.7)
opacity = isSimpleMode ? 1.0 : 0.7;
strokeWidth = 3.0;
} else {
// Local drawing (solid line, normal thickness)
opacity = 1.0;
strokeWidth = 4.0;
}
return Polyline(
points: points,
color: drawing.color.withValues(alpha: opacity),
strokeWidth: strokeWidth,
borderColor: Colors.white.withValues(alpha: opacity * 0.8),
borderStrokeWidth: 1.0,
// Use dotted pattern for received drawings
pattern: drawing.isReceived && !isPreview
? StrokePattern.dotted(spacingFactor: 2)
: const StrokePattern.solid(),
);
}
/// Get points from a drawing based on its type
List<LatLng> _getPoints(MapDrawing drawing) {
if (drawing is LineDrawing) {
return drawing.points;
} else if (drawing is RectangleDrawing) {
return drawing.corners;
}
return [];
}
}
/// Widget that shows drawing markers (start/end points)
class DrawingMarkersLayer extends StatelessWidget {
final List<MapDrawing> drawings;
final Function(String drawingId)? onDeleteDrawing;
final Function(MapDrawing drawing)? onTapDrawing;
final bool showDeleteButtons;
final bool isSimpleMode;
const DrawingMarkersLayer({
super.key,
required this.drawings,
this.onDeleteDrawing,
this.onTapDrawing,
this.showDeleteButtons = false,
this.isSimpleMode = false,
});
@override
Widget build(BuildContext context) {
final List<Marker> markers = [];
// Add markers for each drawing
for (final drawing in drawings) {
final centerPoint = _getCenterPoint(drawing);
if (centerPoint != null) {
if (showDeleteButtons) {
// Show delete button when in drawing mode
markers.add(
Marker(
point: centerPoint,
width: 40,
height: 40,
child: GestureDetector(
onTap: () {
if (onDeleteDrawing != null) {
_showDeleteDialog(context, drawing);
}
},
child: Container(
decoration: BoxDecoration(
color: drawing.color.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 20,
),
),
),
),
);
} else if (drawing.isReceived && drawing.senderName != null && !isSimpleMode) {
// Show sender badge for received drawings (when not in drawing mode and not in simple mode)
// Make it tappable if message ID is available
markers.add(
Marker(
point: centerPoint,
width: 120,
height: 30,
child: GestureDetector(
onTap: drawing.messageId != null && onTapDrawing != null
? () => onTapDrawing!(drawing)
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: drawing.color.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.person,
color: Colors.white,
size: 14,
),
const SizedBox(width: 4),
Flexible(
child: Text(
drawing.senderName!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
// Add indicator that this is tappable
if (drawing.messageId != null && onTapDrawing != null) ...[
const SizedBox(width: 4),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white,
size: 10,
),
],
],
),
),
),
),
);
}
}
}
if (markers.isEmpty) {
return const SizedBox.shrink();
}
return MarkerLayer(markers: markers);
}
/// Get the center point of a drawing
LatLng? _getCenterPoint(MapDrawing drawing) {
if (drawing is LineDrawing && drawing.points.isNotEmpty) {
// Use the middle point of the line
final midIndex = drawing.points.length ~/ 2;
return drawing.points[midIndex];
} else if (drawing is RectangleDrawing) {
// Use the center of the rectangle
return LatLng(
(drawing.topLeft.latitude + drawing.bottomRight.latitude) / 2,
(drawing.topLeft.longitude + drawing.bottomRight.longitude) / 2,
);
}
return null;
}
/// Show delete confirmation dialog
void _showDeleteDialog(BuildContext context, MapDrawing drawing) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.deleteDrawing),
content: Text(
'Delete this ${drawing.type.name}?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
onDeleteDrawing?.call(drawing.id);
},
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: Text(AppLocalizations.of(context)!.delete),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
/// A navigation arrow pointer that indicates the user's location and direction of travel.
///
/// The pointer consists of:
/// - An outer semi-transparent circle representing GPS accuracy
/// - An inner triangular arrow pointing in the direction of travel/heading
/// - Optional rotation based on compass or GPS heading
class LocationPointer extends StatelessWidget {
/// The heading in degrees (0-360, where 0 = North, 90 = East)
/// If null or -1, the pointer will not rotate
final double? heading;
/// The primary color for the pointer
final Color color;
/// The size of the entire widget
final double size;
const LocationPointer({
super.key,
this.heading,
required this.color,
this.size = 40.0,
});
@override
Widget build(BuildContext context) {
// Determine if we have valid heading data
final hasValidHeading = heading != null && heading! >= 0;
// Calculate rotation angle (convert heading to radians)
final rotationAngle = hasValidHeading ? (heading! * 3.14159 / 180.0) : 0.0;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
// Outer accuracy circle (very subtle, uses theme color)
Container(
width: size * 0.6,
height: size * 0.6,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
),
// Inner rotatable arrow pointer (much larger - 90% of size)
Transform.rotate(
angle: rotationAngle,
child: CustomPaint(
size: Size(size * 0.9, size * 0.9),
painter: _NavigationPointerPainter(
color: color,
hasValidHeading: hasValidHeading,
),
),
),
],
),
);
}
}
/// Custom painter that draws a navigation arrow pointer
class _NavigationPointerPainter extends CustomPainter {
final Color color;
final bool hasValidHeading;
_NavigationPointerPainter({
required this.color,
required this.hasValidHeading,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final width = size.width;
final height = size.height;
if (hasValidHeading) {
// Create navigation arrow with V-shaped cutout at bottom
final arrowPath = Path();
// Top point (sharp tip)
arrowPath.moveTo(center.dx, height * 0.08);
// Right side down to bottom right
arrowPath.lineTo(center.dx + width * 0.42, height * 0.92);
// V-cutout at bottom - right side to center
arrowPath.lineTo(center.dx, height * 0.70);
// V-cutout - center to left side
arrowPath.lineTo(center.dx - width * 0.42, height * 0.92);
// Left side back up to top
arrowPath.lineTo(center.dx, height * 0.08);
arrowPath.close();
// Draw shadow for depth
final shadowPaint = Paint()
..color = Colors.black.withValues(alpha: 0.25)
..style = PaintingStyle.fill
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
canvas.save();
canvas.translate(2, 2);
canvas.drawPath(arrowPath, shadowPaint);
canvas.restore();
// Left side (lighter - 70% opacity of theme color)
final leftSidePath = Path();
leftSidePath.moveTo(center.dx, height * 0.08);
leftSidePath.lineTo(center.dx - width * 0.42, height * 0.92);
leftSidePath.lineTo(center.dx, height * 0.70);
leftSidePath.close();
final leftPaint = Paint()
..color = color.withValues(alpha: 0.7)
..style = PaintingStyle.fill;
canvas.drawPath(leftSidePath, leftPaint);
// Right side (darker - full theme color)
final rightSidePath = Path();
rightSidePath.moveTo(center.dx, height * 0.08);
rightSidePath.lineTo(center.dx, height * 0.70);
rightSidePath.lineTo(center.dx + width * 0.42, height * 0.92);
rightSidePath.close();
final rightPaint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawPath(rightSidePath, rightPaint);
// Optional: Draw white border for contrast
final borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..strokeJoin = StrokeJoin.round;
canvas.drawPath(arrowPath, borderPaint);
} else {
// No heading available - draw a circle with white border (uses theme color)
final circlePaint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawCircle(center, width * 0.4, circlePaint);
// White border
final borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
canvas.drawCircle(center, width * 0.4, borderPaint);
// Center white dot
final centerDot = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawCircle(center, width * 0.15, centerDot);
}
}
@override
bool shouldRepaint(_NavigationPointerPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.hasValidHeading != hasValidHeading;
}
}

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:provider/provider.dart';
import '../../providers/map_provider.dart';
/// Widget that renders the user's location trail on the map
class LocationTrailLayer extends StatelessWidget {
const LocationTrailLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<MapProvider>(
builder: (context, mapProvider, child) {
final trail = mapProvider.currentTrail;
final isVisible = mapProvider.isTrailVisible;
// Don't render if trail is hidden or empty
if (!isVisible || trail == null || trail.points.length < 2) {
return const SizedBox.shrink();
}
final points = trail.latLngPoints;
return PolylineLayer(
polylines: [
Polyline(
points: points,
strokeWidth: 4.0,
color: Colors.blue.withValues(alpha: 0.7),
borderStrokeWidth: 2.0,
borderColor: Colors.white.withValues(alpha: 0.5),
),
],
);
},
);
}
}
/// Widget that shows trail statistics overlay
class TrailStatsOverlay extends StatelessWidget {
const TrailStatsOverlay({super.key});
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.toStringAsFixed(0)} m';
} else {
return '${(meters / 1000).toStringAsFixed(2)} km';
}
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s';
}
}
@override
Widget build(BuildContext context) {
return Consumer<MapProvider>(
builder: (context, mapProvider, child) {
final trail = mapProvider.currentTrail;
final isVisible = mapProvider.isTrailVisible;
// Don't show if trail is hidden or doesn't exist
if (!isVisible || trail == null || trail.points.isEmpty) {
return const SizedBox.shrink();
}
final distance = mapProvider.totalTrailDistance;
final duration = mapProvider.trailDuration;
final pointCount = trail.points.length;
return Positioned(
top: 16,
left: 16,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.timeline,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Location Trail',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 8),
_buildStatRow(Icons.straighten, _formatDistance(distance)),
const SizedBox(height: 4),
_buildStatRow(Icons.access_time, _formatDuration(duration)),
const SizedBox(height: 4),
_buildStatRow(Icons.place, '$pointCount points'),
],
),
),
);
},
);
}
Widget _buildStatRow(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: Colors.white70,
size: 16,
),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
],
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
class MapLegend extends StatelessWidget {
final int teamMemberCount;
final int foundPersonCount;
final int fireCount;
final int stagingAreaCount;
final int objectCount;
const MapLegend({
super.key,
required this.teamMemberCount,
required this.foundPersonCount,
required this.fireCount,
required this.stagingAreaCount,
required this.objectCount,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Legend',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
_LegendItem(
icon: Icons.person,
color: Theme.of(context).colorScheme.primary,
label: 'Team',
count: teamMemberCount,
),
_LegendItem(
icon: Icons.person_pin,
color: Colors.green,
label: 'Found',
count: foundPersonCount,
),
_LegendItem(
icon: Icons.local_fire_department,
color: Colors.red,
label: 'Fire',
count: fireCount,
),
_LegendItem(
icon: Icons.home_work,
color: Colors.orange,
label: 'Staging',
count: stagingAreaCount,
),
_LegendItem(
icon: Icons.inventory_2,
color: Colors.purple,
label: 'Object',
count: objectCount,
),
],
),
),
);
}
}
class _LegendItem extends StatelessWidget {
final IconData icon;
final Color color;
final String label;
final int count;
const _LegendItem({
required this.icon,
required this.color,
required this.label,
required this.count,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
count.toString(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../l10n/app_localizations.dart';
import '../messages/message_bubble.dart';
/// Message overlay widget for displaying recent messages on the map
/// Only shown in fullscreen mode on large screens (>= 800px width)
class MapMessageOverlay extends StatefulWidget {
final List<Message> messages;
final VoidCallback? onNavigateToMessages;
final Function(String messageId)? onMessageTap;
const MapMessageOverlay({
super.key,
required this.messages,
this.onNavigateToMessages,
this.onMessageTap,
});
@override
State<MapMessageOverlay> createState() => _MapMessageOverlayState();
}
class _MapMessageOverlayState extends State<MapMessageOverlay> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// Scroll to bottom on initial build
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom(animate: false);
});
}
@override
void didUpdateWidget(MapMessageOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
// Auto-scroll to bottom when new messages arrive
if (widget.messages.length > oldWidget.messages.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom(animate: true);
});
}
}
void _scrollToBottom({bool animate = true}) {
if (_scrollController.hasClients) {
if (animate) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} else {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.messages.isEmpty) {
return const SizedBox.shrink();
}
return Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Row(
children: [
const Icon(
Icons.message,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context)!.recentMessages,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
Text(
'${widget.messages.length}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
),
),
],
),
),
// Message list
Expanded(
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.all(8),
itemCount: widget.messages.length,
separatorBuilder: (context, index) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final message = widget.messages[index];
return MessageBubble(
message: message,
isCompact: true,
onTap: () {
widget.onMessageTap?.call(message.id);
},
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,425 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/map_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/app_provider.dart';
import '../../services/gpx_service.dart';
import '../../services/trail_color_service.dart';
import '../../l10n/app_localizations.dart';
/// Trail management controls widget
class TrailControls extends StatelessWidget {
const TrailControls({super.key});
void _showTrailMenu(BuildContext context) {
final mapProvider = Provider.of<MapProvider>(context, listen: false);
final contactsProvider = Provider.of<ContactsProvider>(context, listen: false);
final appProvider = Provider.of<AppProvider>(context, listen: false);
final l10n = AppLocalizations.of(context)!;
final isSimpleMode = appProvider.isSimpleMode;
// Get contacts with trails (advertHistory >= 2 points)
final contactsWithTrails = contactsProvider.contactsWithLocation
.where((c) => c.advertHistory.length >= 2)
.toList();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.timeline, size: 24),
const SizedBox(width: 12),
Text(
l10n.locationTrail,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 20),
// Trail visibility toggle
SwitchListTile(
secondary: const Icon(Icons.visibility),
title: Text(l10n.showTrailOnMap),
subtitle: Text(
mapProvider.isTrailVisible
? l10n.trailVisible
: l10n.trailHiddenRecording,
),
value: mapProvider.isTrailVisible,
onChanged: (value) {
mapProvider.toggleTrailVisibility();
setModalState(() {}); // Update modal UI
},
),
const Divider(),
const SizedBox(height: 8),
// Trail stats
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatRow(
icon: Icons.straighten,
label: l10n.distance,
value: _formatDistance(mapProvider.totalTrailDistance),
),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.access_time,
label: l10n.duration,
value: _formatDuration(mapProvider.trailDuration),
),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.place,
label: l10n.points,
value: '${mapProvider.currentTrail!.points.length}',
),
],
),
),
const SizedBox(height: 16),
// GPX Export/Import buttons (hidden in simple mode)
if (!isSimpleMode) ...[
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon(
onPressed: () async {
final success = await GpxService.exportTrailToFile(mapProvider.currentTrail!);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? l10n.trailExportedSuccessfully
: l10n.failedToExportTrail),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
},
icon: const Icon(Icons.upload),
label: Text(l10n.exportTrailToGpx),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
try {
final trail = await GpxService.importTrailFromFile();
if (trail != null && context.mounted) {
_showImportDialog(context, mapProvider, trail, l10n);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.failedToImportTrail(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
icon: const Icon(Icons.download),
label: Text(l10n.importTrailFromGpx),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
const SizedBox(height: 16),
],
// Clear trail button
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon(
onPressed: () {
_showClearConfirmation(context, mapProvider, l10n);
},
icon: const Icon(Icons.delete_outline),
label: Text(l10n.clearTrail),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
// No trail message
if (mapProvider.currentTrail == null || mapProvider.currentTrail!.points.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
const Icon(Icons.timeline, size: 48, color: Colors.grey),
const SizedBox(height: 8),
Text(
l10n.noTrailRecorded,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
l10n.startTrackingToRecord,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
// Contact Trails Section
Row(
children: [
const Icon(Icons.people, size: 20),
const SizedBox(width: 8),
Text(
l10n.contactTrails,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
// Show All Contact Trails toggle
SwitchListTile(
secondary: const Icon(Icons.route),
title: Text(l10n.showAllContactTrails),
subtitle: Text(contactsWithTrails.isEmpty
? l10n.noContactsWithLocationHistory
: mapProvider.showAllContactTrails
? l10n.showingTrailsForContacts(contactsWithTrails.length)
: l10n.individualContactTrails),
value: mapProvider.showAllContactTrails,
onChanged: contactsWithTrails.isNotEmpty
? (value) {
mapProvider.toggleAllContactTrails();
setModalState(() {}); // Update modal UI
}
: null, // Disable if no contacts with trails
),
// Individual contact trails (when "show all" is OFF)
if (!mapProvider.showAllContactTrails && contactsWithTrails.isNotEmpty)
ExpansionTile(
title: Text(l10n.individualContactTrails),
initiallyExpanded: false,
children: contactsWithTrails.map((contact) {
final trailColor = TrailColorService.getTrailColor(contact);
final isVisible = mapProvider.isContactPathVisible(contact.publicKeyHex);
return SwitchListTile(
// Color indicator with emoji
secondary: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.roleEmoji != null)
Text(contact.roleEmoji!, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 4),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: trailColor,
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(3),
),
),
],
),
title: Text(contact.displayName),
subtitle: Text('${contact.advertHistory.length} points'),
value: isVisible,
onChanged: (value) {
mapProvider.toggleContactPath(contact.publicKeyHex);
setModalState(() {}); // Update modal UI
},
);
}).toList(),
),
const SizedBox(height: 8),
// Close button
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.close),
),
],
),
),
),
),
);
}
void _showClearConfirmation(BuildContext context, MapProvider mapProvider, AppLocalizations l10n) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.clearTrailQuestion),
content: Text(l10n.clearTrailConfirmation),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
mapProvider.clearCurrentTrail();
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Close bottom sheet
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(l10n.clearTrail),
),
],
),
);
}
void _showImportDialog(BuildContext context, MapProvider mapProvider, trail, AppLocalizations l10n) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.importTrail),
content: Text(l10n.importTrailQuestion(trail.points.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
mapProvider.setImportedTrail(trail);
Navigator.pop(context); // Close dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.trailImported(trail.points.length)),
backgroundColor: Colors.green,
),
);
},
child: Text(l10n.viewAlongside),
),
TextButton(
onPressed: () {
mapProvider.replaceCurrentTrailWithImport(trail);
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Close bottom sheet
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.trailReplaced(trail.points.length)),
backgroundColor: Colors.green,
),
);
},
style: TextButton.styleFrom(foregroundColor: Colors.blue),
child: Text(l10n.replaceCurrent),
),
],
),
);
}
Widget _buildStatRow({
required IconData icon,
required String label,
required String value,
}) {
return Row(
children: [
Icon(icon, size: 18, color: Colors.blue),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
const Spacer(),
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
);
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.toStringAsFixed(0)} m';
} else {
return '${(meters / 1000).toStringAsFixed(2)} km';
}
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m ${seconds}s';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s';
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return FloatingActionButton.small(
heroTag: 'trail_controls',
tooltip: l10n.trailControls,
onPressed: () => _showTrailMenu(context),
child: const Icon(Icons.timeline),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
/// Map Debug Info Widget
/// Displays current zoom level and visible bounds in bottom-left corner
class MapDebugInfo extends StatefulWidget {
final MapController mapController;
const MapDebugInfo({super.key, required this.mapController});
@override
State<MapDebugInfo> createState() => _MapDebugInfoState();
}
class _MapDebugInfoState extends State<MapDebugInfo> {
StreamSubscription<MapEvent>? _mapEventSubscription;
@override
void initState() {
super.initState();
// Listen to map events and trigger rebuild
_mapEventSubscription = widget.mapController.mapEventStream.listen((event) {
if (mounted) {
setState(() {
// Rebuild when map moves, zooms, or rotates
});
}
});
}
@override
void dispose() {
_mapEventSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
try {
final camera = widget.mapController.camera;
final bounds = camera.visibleBounds;
return Card(
color: Colors.black.withValues(alpha: 0.7),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Z: ${camera.zoom.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
const SizedBox(height: 2),
Text(
'N: ${bounds.north.toStringAsFixed(5)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
Text(
'S: ${bounds.south.toStringAsFixed(5)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
Text(
'E: ${bounds.east.toStringAsFixed(5)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
Text(
'W: ${bounds.west.toStringAsFixed(5)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
],
),
),
);
} catch (e) {
// Map not ready yet
return const SizedBox.shrink();
}
}
}

View File

@@ -0,0 +1,395 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/contact.dart';
import '../models/sar_marker.dart';
import '../models/sar_template.dart';
import '../l10n/app_localizations.dart';
class MapMarkers {
static List<Marker> createTeamMemberMarkers(
List<Contact> contacts,
BuildContext context, {
Function(Contact)? onContactTap,
double mapRotation = 0,
}) {
return contacts.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * 3.14159265359 / 180,
child: GestureDetector(
onTap: () {
if (onContactTap != null) {
onContactTap(contact);
} else {
_showContactInfo(context, contact);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: _getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.timeSinceLocationUpdate,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
// Marker icon or emoji
Container(
decoration: BoxDecoration(
color: _getContactTypeColor(contact, context),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
)
: Icon(
_getContactTypeIcon(contact),
color: Colors.white,
size: 18,
),
),
const SizedBox(height: 2),
// Name label (without emoji)
Container(
constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
),
),
),
);
}).whereType<Marker>().toList();
}
static List<Marker> createSarMarkers(
List<SarMarker> sarMarkers,
BuildContext context, {
Function(SarMarker)? onSarMarkerTap,
double mapRotation = 0,
}) {
return sarMarkers.map((marker) {
return Marker(
point: marker.location,
width: 90,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * 3.14159265359 / 180,
child: GestureDetector(
onTap: () {
if (onSarMarkerTap != null) {
onSarMarkerTap(marker);
} else {
_showSarMarkerInfo(context, marker);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Time ago label
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: _getSarMarkerColor(marker),
borderRadius: BorderRadius.circular(3),
),
child: Text(
marker.timeAgo,
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
// Marker emoji/icon
Container(
decoration: BoxDecoration(
color: _getSarMarkerColor(marker),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: Text(
marker.emoji, // Use custom emoji if available
style: const TextStyle(fontSize: 18),
),
),
const SizedBox(height: 2),
// Type label
Container(
constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Builder(
builder: (context) {
// Debug: Print what we're actually displaying
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
debugPrint(' marker.notes: "${marker.notes}"');
debugPrint(' marker.type: ${marker.type}');
debugPrint(' marker.type.displayName: ${marker.type.displayName}');
debugPrint(' marker.displayName: ${marker.displayName}');
return Text(
marker.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
);
},
),
),
],
),
),
),
);
}).toList();
}
static void _showContactInfo(BuildContext context, Contact contact) {
// Import provider to get all contacts and SAR markers for detailed view
// This will be handled by importing the screen's detailed compass dialog
// Since we can't directly access _DetailedCompassDialog from here,
// we'll pass a callback to the screen
// For now, show the simple dialog as a fallback
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
if (contact.roleEmoji != null)
Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24))
else
Icon(Icons.person, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Expanded(child: Text(contact.displayName)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.displayLocation != null) ...[
_InfoRow(
'Location',
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
),
],
if (contact.telemetry?.batteryMilliVolts != null)
_InfoRow(
'Voltage',
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
)
else if (contact.displayBattery != null)
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
if (contact.telemetry?.temperature != null)
_InfoRow(
'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
if (contact.telemetry?.humidity != null)
_InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'),
if (contact.telemetry?.pressure != null)
_InfoRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'),
_InfoRow('Last Seen', contact.timeSinceLastSeen),
_InfoRow('Public Key', contact.publicKeyShort),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
),
);
}
static void _showSarMarkerInfo(BuildContext context, SarMarker marker) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available
const SizedBox(width: 8),
Expanded(child: Text(marker.displayName)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoRow(
'Location',
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
),
_InfoRow('Reported', marker.timeAgo),
if (marker.senderName != null)
_InfoRow('Reporter', marker.senderName!),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
),
);
}
static Color _getLocationAgeColor(Contact contact) {
final updateTime = contact.locationUpdateTime;
if (updateTime == null) return Colors.grey;
final diff = DateTime.now().difference(updateTime);
if (diff.inMinutes < 5) return Colors.green; // Very recent
if (diff.inMinutes < 30) return Colors.lightBlue; // Recent
if (diff.inHours < 2) return Colors.orange; // Getting old
return Colors.red; // Stale
}
static Color _getSarMarkerColor(SarMarker marker) {
// If marker has a color index, use it (new format)
if (marker.colorIndex != null && marker.colorIndex! >= 0 && marker.colorIndex! < 8) {
final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!);
final hexCode = colorHex.replaceAll('#', '');
return Color(int.parse('FF$hexCode', radix: 16));
}
// Otherwise fall back to type-based colors (old format or backward compatibility)
switch (marker.type) {
case SarMarkerType.foundPerson:
return Colors.green;
case SarMarkerType.fire:
return Colors.red;
case SarMarkerType.stagingArea:
return Colors.orange;
case SarMarkerType.object:
return Colors.purple;
case SarMarkerType.unknown:
return Colors.grey;
}
}
static Color _getContactTypeColor(Contact contact, BuildContext context) {
switch (contact.type) {
case ContactType.chat:
return Theme.of(context).colorScheme.primary; // Blue for team members
case ContactType.repeater:
return Colors.deepPurple; // Purple for repeaters
case ContactType.room:
return Colors.teal; // Teal for rooms
case ContactType.channel:
return Colors.orange; // Orange for channels
case ContactType.none:
return Colors.grey;
}
}
static IconData _getContactTypeIcon(Contact contact) {
switch (contact.type) {
case ContactType.chat:
return Icons.person; // Person for team members
case ContactType.repeater:
return Icons.router; // Router icon for repeaters
case ContactType.room:
return Icons.forum; // Forum/chat icon for rooms
case ContactType.channel:
return Icons.public; // Public icon for channels
case ContactType.none:
return Icons.help_outline;
}
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow(this.label, this.value);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(value),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../l10n/app_localizations.dart';
/// Bottom sheet for selecting message recipient (channel, contact, or room)
class RecipientSelectorSheet extends StatefulWidget {
final List<Contact> contacts;
final List<Contact> rooms;
final List<Contact> channels;
final String? currentDestinationType;
final String? currentRecipientPublicKey;
final Function(String type, Contact? recipient) onSelect;
const RecipientSelectorSheet({
super.key,
required this.contacts,
required this.rooms,
required this.channels,
this.currentDestinationType,
this.currentRecipientPublicKey,
required this.onSelect,
});
@override
State<RecipientSelectorSheet> createState() => _RecipientSelectorSheetState();
}
class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Contact> _filterContacts(List<Contact> contacts) {
if (_searchQuery.isEmpty) return contacts;
final query = _searchQuery.toLowerCase();
return contacts.where((contact) {
final name = contact.displayName.toLowerCase();
return name.contains(query);
}).toList();
}
bool _isSelected(String type, Contact? contact) {
if (widget.currentDestinationType != type) return false;
if (contact == null) return false;
return contact.publicKeyHex == widget.currentRecipientPublicKey;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final filteredContacts = _filterContacts(widget.contacts);
final filteredRooms = _filterContacts(widget.rooms);
final filteredChannels = _filterContacts(widget.channels);
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Row(
children: [
Text(
l10n.selectRecipient,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: l10n.close,
),
],
),
),
// Search field
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: l10n.searchRecipients,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
// Recipients list
Flexible(
child: ListView(
shrinkWrap: true,
children: [
// Channels section
if (widget.channels.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
l10n.channels,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
if (filteredChannels.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.noChannelsFound,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
)
else
...filteredChannels.map((channel) {
return _buildRecipientTile(
context: context,
icon: Icons.public,
title: channel.getLocalizedDisplayName(context),
subtitle: channel.isPublicChannel
? l10n.broadcastToAllNearby
: '${l10n.channel} ${channel.publicKey[1]}', // Show slot number
isSelected: _isSelected('channel', channel),
onTap: () {
widget.onSelect('channel', channel);
Navigator.pop(context);
},
);
}),
],
const Divider(),
// Contacts section
if (widget.contacts.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
l10n.contacts,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
if (filteredContacts.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.noContactsFound,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
)
else
...filteredContacts.map((contact) {
return _buildRecipientTile(
context: context,
icon: Icons.person,
title: contact.displayName,
subtitle: contact.publicKeyShort,
emoji: contact.roleEmoji,
isSelected: _isSelected('contact', contact),
onTap: () {
widget.onSelect('contact', contact);
Navigator.pop(context);
},
);
}),
],
const Divider(),
// Rooms section
if (widget.rooms.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
l10n.rooms,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
if (filteredRooms.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.noRoomsFound,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
)
else
...filteredRooms.map((room) {
return _buildRecipientTile(
context: context,
icon: Icons.meeting_room,
title: room.displayName,
subtitle: room.publicKeyShort,
emoji: room.roleEmoji,
isSelected: _isSelected('room', room),
onTap: () {
widget.onSelect('room', room);
Navigator.pop(context);
},
);
}),
],
// Empty state
if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Icons.people_outline,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
l10n.noRecipientsAvailable,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: Theme.of(context).disabledColor,
),
textAlign: TextAlign.center,
),
],
),
),
],
const SizedBox(height: 16),
],
),
),
],
),
);
}
Widget _buildRecipientTile({
required BuildContext context,
required IconData icon,
required String title,
required String subtitle,
String? emoji,
required bool isSelected,
required VoidCallback onTap,
}) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
title: Row(
children: [
if (emoji != null && emoji.isNotEmpty) ...[
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
],
),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 12, fontFamily: 'monospace').copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
onTap: onTap,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
/// Dialog that requests location permissions on app startup
class PermissionRequestDialog extends StatefulWidget {
final VoidCallback onPermissionsGranted;
final VoidCallback? onPermissionsDenied;
const PermissionRequestDialog({
super.key,
required this.onPermissionsGranted,
this.onPermissionsDenied,
});
@override
State<PermissionRequestDialog> createState() => _PermissionRequestDialogState();
}
class _PermissionRequestDialogState extends State<PermissionRequestDialog> {
bool _isRequesting = false;
String? _errorMessage;
@override
void initState() {
super.initState();
// Automatically check and request permissions when dialog opens
_checkAndRequestPermissions();
}
Future<void> _checkAndRequestPermissions() async {
if (_isRequesting) return;
setState(() {
_isRequesting = true;
_errorMessage = null;
});
try {
// Check if location service is enabled
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!mounted) return;
if (!serviceEnabled) {
setState(() {
_errorMessage = 'Location services are disabled. Please enable location services in your device settings.';
_isRequesting = false;
});
return;
}
// Check current permission
LocationPermission permission = await Geolocator.checkPermission();
if (!mounted) return;
if (permission == LocationPermission.denied) {
// Request permission
permission = await Geolocator.requestPermission();
if (!mounted) return;
}
if (permission == LocationPermission.denied) {
setState(() {
_errorMessage = 'Location permission denied. This app requires location access to track your position and share it with your team.';
_isRequesting = false;
});
widget.onPermissionsDenied?.call();
return;
}
if (permission == LocationPermission.deniedForever) {
setState(() {
_errorMessage = 'Location permission permanently denied. Please enable location access in your device settings.';
_isRequesting = false;
});
widget.onPermissionsDenied?.call();
return;
}
// Permission granted!
setState(() {
_isRequesting = false;
});
// Close dialog and notify parent
Navigator.of(context).pop();
widget.onPermissionsGranted();
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Error requesting permissions: $e';
_isRequesting = false;
});
}
}
@override
Widget build(BuildContext context) {
return PopScope(
// Allow dismissing dialog by back button or tapping outside
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
widget.onPermissionsDenied?.call();
}
},
child: AlertDialog(
title: Row(
children: [
Icon(
Icons.location_on,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
const Text('Location Permission'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'MeshCore SAR needs access to your location to:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildPermissionReason(
icon: Icons.track_changes,
text: 'Track your position during search and rescue operations',
),
const SizedBox(height: 8),
_buildPermissionReason(
icon: Icons.share_location,
text: 'Share your location with team members via mesh network',
),
const SizedBox(height: 8),
_buildPermissionReason(
icon: Icons.map,
text: 'Display your location and trail on the map',
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
],
),
),
],
if (_isRequesting) ...[
const SizedBox(height: 16),
const Center(
child: CircularProgressIndicator(),
),
],
],
),
actions: [
// Always show a cancel/skip button
TextButton(
onPressed: () {
Navigator.of(context).pop();
widget.onPermissionsDenied?.call();
},
child: const Text('Skip'),
),
if (_errorMessage != null && !_isRequesting)
ElevatedButton(
onPressed: () async {
// Open app settings
await Geolocator.openLocationSettings();
},
child: const Text('Open Settings'),
),
if (_errorMessage != null && !_isRequesting &&
!_errorMessage!.contains('permanently denied'))
ElevatedButton(
onPressed: _checkAndRequestPermissions,
child: const Text('Retry'),
),
],
),
);
}
Widget _buildPermissionReason({
required IconData icon,
required String text,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 14),
),
),
],
);
}
}

View File

@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import '../../models/sar_template.dart';
import '../../l10n/app_localizations.dart';
/// Dialog for adding or editing SAR templates
class SarTemplateEditDialog extends StatefulWidget {
final SarTemplate? template; // Null for new template
final Function(SarTemplate) onSave;
const SarTemplateEditDialog({
super.key,
this.template,
required this.onSave,
});
@override
State<SarTemplateEditDialog> createState() => _SarTemplateEditDialogState();
}
class _SarTemplateEditDialogState extends State<SarTemplateEditDialog> {
late TextEditingController _emojiController;
late TextEditingController _nameController;
late TextEditingController _descriptionController;
late String _selectedColor;
final List<Map<String, dynamic>> _colorOptions = [
{'name': 'Green', 'hex': '#4CAF50'},
{'name': 'Red', 'hex': '#F44336'},
{'name': 'Orange', 'hex': '#FF9800'},
{'name': 'Purple', 'hex': '#9C27B0'},
{'name': 'Blue', 'hex': '#2196F3'},
{'name': 'Yellow', 'hex': '#FFC107'},
{'name': 'Brown', 'hex': '#795548'},
{'name': 'Gray', 'hex': '#9E9E9E'},
];
String? _emojiError;
String? _nameError;
@override
void initState() {
super.initState();
_emojiController = TextEditingController(text: widget.template?.emoji ?? '');
_nameController = TextEditingController(text: widget.template?.name ?? '');
_descriptionController = TextEditingController(text: widget.template?.description ?? '');
_selectedColor = widget.template?.colorHex ?? '#4CAF50';
}
@override
void dispose() {
_emojiController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
bool _validate() {
final l10n = AppLocalizations.of(context)!;
setState(() {
_emojiError = null;
_nameError = null;
});
bool isValid = true;
if (_emojiController.text.trim().isEmpty) {
setState(() {
_emojiError = l10n.emojiRequired;
});
isValid = false;
}
if (_nameController.text.trim().isEmpty) {
setState(() {
_nameError = l10n.nameRequired;
});
isValid = false;
}
return isValid;
}
void _save() {
if (!_validate()) return;
final template = SarTemplate(
id: widget.template?.id ?? 'custom_${DateTime.now().millisecondsSinceEpoch}',
emoji: _emojiController.text.trim(),
name: _nameController.text.trim(),
description: _descriptionController.text.trim(),
colorHex: _selectedColor,
isDefault: widget.template?.isDefault ?? false,
);
widget.onSave(template);
Navigator.of(context).pop();
}
String _getPreview() {
final emoji = _emojiController.text.trim();
final description = _descriptionController.text.trim();
if (emoji.isEmpty) return 'S::0,0';
if (description.isEmpty) return 'S:$emoji:0,0';
return 'S:$emoji:0,0:$description';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = AppLocalizations.of(context)!;
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Text(
widget.template == null ? l10n.addTemplate : l10n.editTemplate,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 24),
// Emoji field
TextField(
controller: _emojiController,
decoration: InputDecoration(
labelText: l10n.templateEmoji,
hintText: '🧑',
errorText: _emojiError,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.emoji_emotions),
),
maxLength: 4,
style: const TextStyle(fontSize: 24),
textAlign: TextAlign.center,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Name field
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.templateName,
hintText: l10n.templateNameHint,
errorText: _nameError,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.label),
),
maxLength: 30,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Description field
TextField(
controller: _descriptionController,
decoration: InputDecoration(
labelText: l10n.templateDescription,
hintText: l10n.templateDescriptionHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.description),
),
maxLength: 100,
maxLines: 2,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Color picker
Text(
l10n.templateColor,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: _colorOptions.map((colorOption) {
final hex = colorOption['hex'] as String;
final color = Color(int.parse('FF${hex.replaceAll('#', '')}', radix: 16));
final isSelected = _selectedColor == hex;
return GestureDetector(
onTap: () => setState(() => _selectedColor = hex),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? colorScheme.primary : Colors.transparent,
width: 3,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.3),
blurRadius: 8,
spreadRadius: 2,
),
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
const SizedBox(height: 24),
// Preview
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.previewFormat,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_getPreview(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: colorScheme.onSurface,
),
),
],
),
),
const SizedBox(height: 24),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: Text(l10n.save),
),
],
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/update_info.dart';
import '../l10n/app_localizations.dart';
/// Dialog widget that displays when a new app version is available
/// Shows current vs latest commit hash and provides download button
class UpdateDialog extends StatelessWidget {
final UpdateInfo updateInfo;
const UpdateDialog({
super.key,
required this.updateInfo,
});
/// Show the update dialog
static Future<void> show(BuildContext context, UpdateInfo updateInfo) {
return showDialog(
context: context,
barrierDismissible: true,
builder: (context) => UpdateDialog(updateInfo: updateInfo),
);
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return AlertDialog(
icon: const Icon(
Icons.system_update,
size: 48,
color: Colors.blue,
),
title: Text(loc.updateAvailable),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Current version
_buildInfoRow(
context,
label: loc.currentVersion,
value: updateInfo.currentCommitHash,
),
const SizedBox(height: 12),
// Latest version
_buildInfoRow(
context,
label: loc.latestVersion,
value: updateInfo.latestCommitHash ?? 'unknown',
),
// Optional: Build timestamp
if (updateInfo.timestamp != null) ...[
const SizedBox(height: 12),
_buildInfoRow(
context,
label: 'Build Time',
value: _formatTimestamp(updateInfo.timestamp!),
),
],
],
),
actions: [
// Later button
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.updateLater),
),
// Download button
FilledButton.icon(
onPressed: () => _launchDownloadUrl(context),
icon: const Icon(Icons.download),
label: Text(loc.downloadUpdate),
),
],
);
}
/// Build a labeled info row
Widget _buildInfoRow(BuildContext context, {required String label, required String value}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
),
Expanded(
child: SelectableText(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
color: Colors.black87,
),
),
),
],
);
}
/// Format timestamp from YYYYMMDD-HHMMSS to readable format
String _formatTimestamp(String timestamp) {
try {
// Parse YYYYMMDD-HHMMSS format
if (timestamp.length >= 15) {
final year = timestamp.substring(0, 4);
final month = timestamp.substring(4, 6);
final day = timestamp.substring(6, 8);
final hour = timestamp.substring(9, 11);
final minute = timestamp.substring(11, 13);
return '$year-$month-$day $hour:$minute UTC';
}
return timestamp;
} catch (e) {
return timestamp;
}
}
/// Launch download URL in browser
Future<void> _launchDownloadUrl(BuildContext context) async {
if (updateInfo.downloadUrl == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Download URL not available'),
backgroundColor: Colors.red,
),
);
}
return;
}
try {
final url = Uri.parse(updateInfo.downloadUrl!);
final canLaunch = await canLaunchUrl(url);
if (!canLaunch) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cannot open download URL'),
backgroundColor: Colors.red,
),
);
}
return;
}
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
// Close dialog after launching download
if (context.mounted) {
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('[UpdateDialog] Error launching download URL: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error opening download: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}