mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: MeshCore SAR - Flutter BLE mesh radio companion app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
272
lib/widgets/common/location_display.dart
Normal file
272
lib/widgets/common/location_display.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
666
lib/widgets/connection_dialog.dart
Normal file
666
lib/widgets/connection_dialog.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
155
lib/widgets/connection_mode_selector.dart
Normal file
155
lib/widgets/connection_mode_selector.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
251
lib/widgets/contacts/add_channel_dialog.dart
Normal file
251
lib/widgets/contacts/add_channel_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1269
lib/widgets/contacts/contact_tile.dart
Normal file
1269
lib/widgets/contacts/contact_tile.dart
Normal file
File diff suppressed because it is too large
Load Diff
452
lib/widgets/contacts/direct_message_sheet.dart
Normal file
452
lib/widgets/contacts/direct_message_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
506
lib/widgets/contacts/room_login_sheet.dart
Normal file
506
lib/widgets/contacts/room_login_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/widgets/contacts/section_header.dart
Normal file
45
lib/widgets/contacts/section_header.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/widgets/drawing_minimap_preview.dart
Normal file
140
lib/widgets/drawing_minimap_preview.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
288
lib/widgets/map/compass/compass_contact_list.dart
Normal file
288
lib/widgets/map/compass/compass_contact_list.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
218
lib/widgets/map/compass/compass_filters.dart
Normal file
218
lib/widgets/map/compass/compass_filters.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
609
lib/widgets/map/compass/compass_header.dart
Normal file
609
lib/widgets/map/compass/compass_header.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
236
lib/widgets/map/compass/compass_sar_list.dart
Normal file
236
lib/widgets/map/compass/compass_sar_list.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
107
lib/widgets/map/compass_widget.dart
Normal file
107
lib/widgets/map/compass_widget.dart
Normal 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;
|
||||
}
|
||||
765
lib/widgets/map/detailed_compass_dialog.dart
Normal file
765
lib/widgets/map/detailed_compass_dialog.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
108
lib/widgets/map/download_area_overlay.dart
Normal file
108
lib/widgets/map/download_area_overlay.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
261
lib/widgets/map/drawing_layer.dart
Normal file
261
lib/widgets/map/drawing_layer.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1101
lib/widgets/map/drawing_toolbar.dart
Normal file
1101
lib/widgets/map/drawing_toolbar.dart
Normal file
File diff suppressed because it is too large
Load Diff
175
lib/widgets/map/location_pointer.dart
Normal file
175
lib/widgets/map/location_pointer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
155
lib/widgets/map/location_trail_layer.dart
Normal file
155
lib/widgets/map/location_trail_layer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
116
lib/widgets/map/map_legend.dart
Normal file
116
lib/widgets/map/map_legend.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/widgets/map/map_message_overlay.dart
Normal file
146
lib/widgets/map/map_message_overlay.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
425
lib/widgets/map/trail_controls.dart
Normal file
425
lib/widgets/map/trail_controls.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/widgets/map_debug_info.dart
Normal file
102
lib/widgets/map_debug_info.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
395
lib/widgets/map_markers.dart
Normal file
395
lib/widgets/map_markers.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1281
lib/widgets/messages/message_bubble.dart
Normal file
1281
lib/widgets/messages/message_bubble.dart
Normal file
File diff suppressed because it is too large
Load Diff
370
lib/widgets/messages/recipient_selector_sheet.dart
Normal file
370
lib/widgets/messages/recipient_selector_sheet.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
1287
lib/widgets/messages/sar_update_sheet.dart
Normal file
1287
lib/widgets/messages/sar_update_sheet.dart
Normal file
File diff suppressed because it is too large
Load Diff
217
lib/widgets/permission_request_dialog.dart
Normal file
217
lib/widgets/permission_request_dialog.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
321
lib/widgets/sar/sar_template_edit_dialog.dart
Normal file
321
lib/widgets/sar/sar_template_edit_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
180
lib/widgets/update_dialog.dart
Normal file
180
lib/widgets/update_dialog.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user