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:
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user