feat: MeshCore SAR - Flutter BLE mesh radio companion app

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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