mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Refactor SAR marker handling and add template management
- Updated CompassSarList and DetailedCompassDialog to use marker.displayName instead of marker.type.displayName. - Enhanced DrawingLayer and DrawingMarkersLayer to support simple mode for drawing visibility and interaction. - Added toggle switches in DrawingToolbar for showing/hiding received drawings and SAR markers. - Modified MapMarkers to utilize custom emojis and display names for markers. - Introduced RecipientSelectorSheet for selecting message recipients with search functionality. - Refactored SarUpdateSheet to use SAR templates instead of marker types, allowing for emoji and name customization. - Created SarTemplateEditDialog for adding and editing SAR templates with color selection and preview.
This commit is contained in:
@@ -8,6 +8,7 @@ import '../../models/room_login_state.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/map_provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import 'direct_message_sheet.dart';
|
||||
import 'room_login_sheet.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
@@ -18,6 +19,7 @@ class ContactTile extends StatelessWidget {
|
||||
final Position? currentPosition;
|
||||
final double Function(double, double, double, double)? calculateDistance;
|
||||
final String Function(double)? formatDistance;
|
||||
final VoidCallback? onNavigateToMap;
|
||||
|
||||
const ContactTile({
|
||||
super.key,
|
||||
@@ -25,10 +27,14 @@ class ContactTile extends StatelessWidget {
|
||||
this.currentPosition,
|
||||
this.calculateDistance,
|
||||
this.formatDistance,
|
||||
this.onNavigateToMap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appProvider = context.watch<AppProvider>();
|
||||
final isSimpleMode = appProvider.isSimpleMode;
|
||||
|
||||
final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent;
|
||||
final battery = contact.displayBattery;
|
||||
final location = contact.displayLocation;
|
||||
@@ -113,8 +119,8 @@ class ContactTile extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Battery indicator
|
||||
if (battery != null) ...[
|
||||
// Battery indicator - hidden in simple mode
|
||||
if (!isSimpleMode && battery != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_getBatteryIcon(battery),
|
||||
@@ -130,8 +136,8 @@ class ContactTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
// Connection type indicator (direct/flood) - shown for all contact types
|
||||
if (contact.type != ContactType.channel) ...[
|
||||
// Connection type indicator (direct/flood) - hidden in simple mode
|
||||
if (!isSimpleMode && contact.type != ContactType.channel) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
@@ -168,129 +174,186 @@ class ContactTile extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
// Room login status badges
|
||||
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
|
||||
Row(
|
||||
subtitle: isSimpleMode
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (roomLoginState.isAdmin)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.red, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.admin,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (roomLoginState.isAdmin) const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.green, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
const SizedBox(height: 4),
|
||||
// Simple mode: Only show location and distance
|
||||
if (location != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, size: 10, color: Colors.green),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.loggedIn,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
const Icon(Icons.location_on, size: 12, color: Colors.blue),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// Last seen + GPS info combined
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
contact.timeSinceLastSeen,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
if (location != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(width: 8),
|
||||
if (hasTelemetry)
|
||||
const Icon(Icons.sensors, size: 12, color: Colors.green)
|
||||
else
|
||||
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'GPS: ${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
if (distanceText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.straighten, size: 12, color: Colors.blue),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
] else
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noGpsData,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(width: 8),
|
||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noGpsData,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Distance info (new row)
|
||||
if (distanceText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.straighten, size: 12, color: Colors.blue),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
const SizedBox(height: 4),
|
||||
// Room login status badges
|
||||
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
|
||||
Row(
|
||||
children: [
|
||||
if (roomLoginState.isAdmin)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.red, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.admin,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (roomLoginState.isAdmin) const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.green, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.check_circle, size: 10, color: Colors.green),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.loggedIn,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// Last seen + GPS info combined
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
contact.timeSinceLastSeen,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
if (location != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(width: 8),
|
||||
if (hasTelemetry)
|
||||
const Icon(Icons.sensors, size: 12, color: Colors.green)
|
||||
else
|
||||
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(width: 8),
|
||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noGpsData,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Distance info (new row)
|
||||
if (distanceText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.straighten, size: 12, color: Colors.blue),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: null,
|
||||
onTap: () => _showContactDetails(context, contact),
|
||||
onTap: () {
|
||||
// In simple mode, tap directly opens message sheet for chat contacts
|
||||
if (isSimpleMode && contact.type == ContactType.chat) {
|
||||
_showDirectMessageDialog(context, contact);
|
||||
} else if (isSimpleMode && contact.type == ContactType.repeater) {
|
||||
// In simple mode, tapping a repeater jumps to the map
|
||||
_jumpToMapForRepeater(context, contact);
|
||||
} else if (isSimpleMode && contact.type == ContactType.room && !contact.isPublicChannel) {
|
||||
_showRoomLoginDialog(context, contact);
|
||||
} else {
|
||||
_showContactDetails(context, contact);
|
||||
}
|
||||
},
|
||||
onLongPress: () async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
@@ -360,6 +423,29 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _jumpToMapForRepeater(BuildContext context, Contact contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location != null) {
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
|
||||
// Navigate to map location
|
||||
mapProvider.navigateToLocation(
|
||||
location: LatLng(location.latitude, location.longitude),
|
||||
zoom: 15.0,
|
||||
animate: true,
|
||||
);
|
||||
|
||||
// Switch to map tab using callback
|
||||
onNavigateToMap?.call();
|
||||
} else {
|
||||
// No location available, just show toast
|
||||
ToastLogger.info(
|
||||
context,
|
||||
'Repeater ${contact.displayName} has no location data',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation(BuildContext context, Contact contact) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -587,8 +673,8 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
Navigator.pop(context);
|
||||
|
||||
// Switch to map tab (assuming it's index 2)
|
||||
DefaultTabController.of(context).animateTo(2);
|
||||
// Switch to map tab using callback
|
||||
onNavigateToMap?.call();
|
||||
},
|
||||
icon: const Icon(Icons.map, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.viewOnMap),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -174,6 +176,9 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final appProvider = context.watch<AppProvider>();
|
||||
final isSimpleMode = appProvider.isSimpleMode;
|
||||
final contactLocation = widget.contact.displayLocation;
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
@@ -222,35 +227,90 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
),
|
||||
),
|
||||
|
||||
// Info banner
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.directMessageInfo(widget.contact.displayName),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontSize: 13,
|
||||
// Mini map in simple mode (scrollable content)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
if (isSimpleMode && contactLocation != null) ...[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// Hide keyboard when tapping on map
|
||||
_focusNode.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: LatLng(
|
||||
contactLocation.latitude,
|
||||
contactLocation.longitude,
|
||||
),
|
||||
initialZoom: 13.0,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: LatLng(
|
||||
contactLocation.latitude,
|
||||
contactLocation.longitude,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(
|
||||
Icons.location_on,
|
||||
color: colorScheme.primary,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
// Location coordinates
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.gps_fixed, size: 14, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Message input
|
||||
Container(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -321,7 +381,7 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
OutlinedButton.icon(
|
||||
onPressed: _insertCurrentLocation,
|
||||
icon: const Icon(Icons.my_location, size: 18),
|
||||
label: const Text('Location'),
|
||||
label: Text(AppLocalizations.of(context)!.myLocation),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
|
||||
@@ -124,7 +124,7 @@ class CompassSarList extends StatelessWidget {
|
||||
color: markerColor,
|
||||
size: 24,
|
||||
),
|
||||
title: Text(marker.type.displayName),
|
||||
title: Text(marker.displayName),
|
||||
subtitle: Text(
|
||||
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)} • ${marker.timeAgo}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
|
||||
@@ -534,7 +534,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
|
||||
additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%';
|
||||
}
|
||||
} else if (_selectedSarMarker != null) {
|
||||
title = _selectedSarMarker!.type.displayName;
|
||||
title = _selectedSarMarker!.displayName;
|
||||
targetLocation = _selectedSarMarker!.location;
|
||||
additionalInfo = _selectedSarMarker!.timeAgo;
|
||||
|
||||
@@ -664,21 +664,21 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
|
||||
children: [
|
||||
_buildLargeInfoCard(
|
||||
context,
|
||||
'Distance',
|
||||
AppLocalizations.of(context)!.distance,
|
||||
_formatDistance(distance),
|
||||
Icons.straighten,
|
||||
color,
|
||||
),
|
||||
_buildLargeInfoCard(
|
||||
context,
|
||||
'Bearing',
|
||||
AppLocalizations.of(context)!.bearing,
|
||||
'${bearing.round()}°',
|
||||
Icons.navigation,
|
||||
color,
|
||||
),
|
||||
_buildLargeInfoCard(
|
||||
context,
|
||||
'Direction',
|
||||
AppLocalizations.of(context)!.direction,
|
||||
_bearingToCardinal(bearing),
|
||||
Icons.explore,
|
||||
color,
|
||||
|
||||
@@ -8,11 +8,13 @@ import '../../l10n/app_localizations.dart';
|
||||
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
|
||||
@@ -45,8 +47,9 @@ class DrawingLayer extends StatelessWidget {
|
||||
opacity = 0.6;
|
||||
strokeWidth = 4.0;
|
||||
} else if (drawing.isReceived) {
|
||||
// Received drawing from another node (thinner, more transparent)
|
||||
opacity = 0.7;
|
||||
// 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)
|
||||
@@ -82,13 +85,17 @@ class DrawingLayer extends StatelessWidget {
|
||||
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
|
||||
@@ -134,49 +141,64 @@ class DrawingMarkersLayer extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (drawing.isReceived && drawing.senderName != null) {
|
||||
// Show sender badge for received drawings (when not in drawing mode)
|
||||
} 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: 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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -201,6 +201,43 @@ class DrawingToolbar extends StatelessWidget {
|
||||
drawingProvider.setDrawingMode(DrawingMode.rectangle);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// Toggle received drawings visibility
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
drawingProvider.showReceivedDrawings
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
title: Text(AppLocalizations.of(context)!.showReceivedDrawings),
|
||||
subtitle: Text(
|
||||
drawingProvider.showReceivedDrawings
|
||||
? AppLocalizations.of(context)!.showingAllDrawings
|
||||
: AppLocalizations.of(context)!.showingOnlyYourDrawings,
|
||||
),
|
||||
value: drawingProvider.showReceivedDrawings,
|
||||
onChanged: (value) {
|
||||
drawingProvider.toggleReceivedDrawings();
|
||||
},
|
||||
),
|
||||
// Toggle SAR markers visibility
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
drawingProvider.showSarMarkers
|
||||
? Icons.pin_drop
|
||||
: Icons.pin_drop_outlined,
|
||||
),
|
||||
title: Text(AppLocalizations.of(context)!.showSarMarkers),
|
||||
subtitle: Text(
|
||||
drawingProvider.showSarMarkers
|
||||
? AppLocalizations.of(context)!.showingSarMarkers
|
||||
: AppLocalizations.of(context)!.hidingSarMarkers,
|
||||
),
|
||||
value: drawingProvider.showSarMarkers,
|
||||
onChanged: (value) {
|
||||
drawingProvider.toggleSarMarkers();
|
||||
},
|
||||
),
|
||||
if (drawingProvider.drawings.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
ListTile(
|
||||
|
||||
@@ -158,7 +158,7 @@ class MapMarkers {
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
marker.type.emoji,
|
||||
marker.emoji, // Use custom emoji if available
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
@@ -171,16 +171,27 @@ class MapMarkers {
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.type.getLocalizedName(context),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Debug: Print what we're actually displaying
|
||||
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
|
||||
debugPrint(' marker.notes: "${marker.notes}"');
|
||||
debugPrint(' marker.type: ${marker.type}');
|
||||
debugPrint(' marker.type.displayName: ${marker.type.displayName}');
|
||||
debugPrint(' marker.displayName: ${marker.displayName}');
|
||||
|
||||
return Text(
|
||||
marker.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -252,9 +263,9 @@ class MapMarkers {
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(marker.type.emoji, style: const TextStyle(fontSize: 24)),
|
||||
Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(marker.type.getLocalizedName(context))),
|
||||
Expanded(child: Text(marker.displayName)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
|
||||
344
lib/widgets/messages/recipient_selector_sheet.dart
Normal file
344
lib/widgets/messages/recipient_selector_sheet.dart
Normal file
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// Bottom sheet for selecting message recipient (channel, contact, or room)
|
||||
class RecipientSelectorSheet extends StatefulWidget {
|
||||
final List<Contact> contacts;
|
||||
final List<Contact> rooms;
|
||||
final String? currentDestinationType;
|
||||
final String? currentRecipientPublicKey;
|
||||
final Function(String type, Contact? recipient) onSelect;
|
||||
|
||||
const RecipientSelectorSheet({
|
||||
super.key,
|
||||
required this.contacts,
|
||||
required this.rooms,
|
||||
this.currentDestinationType,
|
||||
this.currentRecipientPublicKey,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecipientSelectorSheet> createState() => _RecipientSelectorSheetState();
|
||||
}
|
||||
|
||||
class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Contact> _filterContacts(List<Contact> contacts) {
|
||||
if (_searchQuery.isEmpty) return contacts;
|
||||
final query = _searchQuery.toLowerCase();
|
||||
return contacts.where((contact) {
|
||||
final name = contact.displayName?.toLowerCase() ?? contact.advName.toLowerCase();
|
||||
return name.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
bool _isSelected(String type, Contact? contact) {
|
||||
if (widget.currentDestinationType != type) return false;
|
||||
if (type == 'channel') return true;
|
||||
if (contact == null) return false;
|
||||
return contact.publicKeyHex == widget.currentRecipientPublicKey;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final filteredContacts = _filterContacts(widget.contacts);
|
||||
final filteredRooms = _filterContacts(widget.rooms);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.selectRecipient,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: l10n.close,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Search field
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchRecipients,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Recipients list
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
// Public Channel option
|
||||
_buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.public,
|
||||
title: l10n.publicChannel,
|
||||
subtitle: l10n.broadcastToAllNearby,
|
||||
isSelected: _isSelected('channel', null),
|
||||
onTap: () {
|
||||
widget.onSelect('channel', null);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Contacts section
|
||||
if (widget.contacts.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
l10n.contacts,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (filteredContacts.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
l10n.noContactsFound,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...filteredContacts.map((contact) {
|
||||
return _buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.person,
|
||||
title: contact.displayName ?? contact.advName,
|
||||
subtitle: contact.publicKeyShort,
|
||||
emoji: contact.roleEmoji,
|
||||
isSelected: _isSelected('contact', contact),
|
||||
onTap: () {
|
||||
widget.onSelect('contact', contact);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Rooms section
|
||||
if (widget.rooms.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
l10n.rooms,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (filteredRooms.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
l10n.noRoomsFound,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...filteredRooms.map((room) {
|
||||
return _buildRecipientTile(
|
||||
context: context,
|
||||
icon: Icons.meeting_room,
|
||||
title: room.displayName ?? room.advName,
|
||||
subtitle: room.publicKeyShort,
|
||||
emoji: room.roleEmoji,
|
||||
isSelected: _isSelected('room', room),
|
||||
onTap: () {
|
||||
widget.onSelect('room', room);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
// Empty state
|
||||
if (widget.contacts.isEmpty && widget.rooms.isEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.noContactsOrRoomsAvailable,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.messagesWillBeSentToPublicChannel,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecipientTile({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
String? emoji,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
if (emoji != null && emoji.isNotEmpty) ...[
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
).copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,15 @@ import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/sar_marker.dart';
|
||||
import '../../models/sar_template.dart';
|
||||
import '../../services/validation_service.dart';
|
||||
import '../../services/sar_template_service.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// SAR Update Sheet - Modal bottom sheet for creating and sending SAR markers
|
||||
/// This widget is public so it can be used from both messages_tab.dart and map_tab.dart
|
||||
class SarUpdateSheet extends StatefulWidget {
|
||||
final Future<void> Function(SarMarkerType, Position, String?, Uint8List?, bool) onSend;
|
||||
final Future<void> Function(String emoji, String name, Position, Uint8List?, bool) onSend;
|
||||
final Position? prePopulatedPosition;
|
||||
final bool allowLocationUpdate;
|
||||
|
||||
@@ -27,7 +28,9 @@ class SarUpdateSheet extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
SarMarkerType _selectedType = SarMarkerType.foundPerson;
|
||||
SarTemplate? _selectedTemplate;
|
||||
List<SarTemplate> _templates = [];
|
||||
final SarTemplateService _templateService = SarTemplateService();
|
||||
Position? _currentPosition;
|
||||
bool _loadingLocation = false;
|
||||
String? _locationError;
|
||||
@@ -37,6 +40,7 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeTemplates();
|
||||
// Use pre-populated position if provided, otherwise get current location
|
||||
if (widget.prePopulatedPosition != null) {
|
||||
_currentPosition = widget.prePopulatedPosition;
|
||||
@@ -46,6 +50,21 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
_setDefaultDestination();
|
||||
}
|
||||
|
||||
Future<void> _initializeTemplates() async {
|
||||
if (!_templateService.isInitialized) {
|
||||
await _templateService.initialize();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_templates = _templateService.templates;
|
||||
// Select first template by default
|
||||
if (_templates.isNotEmpty) {
|
||||
_selectedTemplate = _templates.first;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setDefaultDestination() {
|
||||
// Set default to first room, or first channel if no rooms exist
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -140,19 +159,23 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
// Get keyboard height to adjust padding
|
||||
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
return AnimatedPadding(
|
||||
padding: EdgeInsets.only(bottom: keyboardHeight),
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
@@ -196,7 +219,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: keyboardHeight > 0 ? keyboardHeight + 16 : 16,
|
||||
// Add bottom padding for button area (button + padding + safe area)
|
||||
// Button height ~48px + container padding 32px + safe area
|
||||
bottom: 80 + bottomSafeArea,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -211,30 +236,17 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MarkerTypeChip(
|
||||
type: SarMarkerType.foundPerson,
|
||||
isSelected: _selectedType == SarMarkerType.foundPerson,
|
||||
onTap: () => setState(() => _selectedType = SarMarkerType.foundPerson),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
MarkerTypeChip(
|
||||
type: SarMarkerType.fire,
|
||||
isSelected: _selectedType == SarMarkerType.fire,
|
||||
onTap: () => setState(() => _selectedType = SarMarkerType.fire),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
MarkerTypeChip(
|
||||
type: SarMarkerType.stagingArea,
|
||||
isSelected: _selectedType == SarMarkerType.stagingArea,
|
||||
onTap: () => setState(() => _selectedType = SarMarkerType.stagingArea),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
MarkerTypeChip(
|
||||
type: SarMarkerType.object,
|
||||
isSelected: _selectedType == SarMarkerType.object,
|
||||
onTap: () => setState(() => _selectedType = SarMarkerType.object),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
..._templates.map((template) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: TemplateChip(
|
||||
template: template,
|
||||
isSelected: _selectedTemplate?.id == template.id,
|
||||
onTap: () => setState(() => _selectedTemplate = template),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Destination selection (compact dropdown with rooms and channel)
|
||||
Text(
|
||||
@@ -595,35 +607,28 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
// Bottom action button
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _currentPosition == null || _selectedContact == null
|
||||
onPressed: _currentPosition == null || _selectedContact == null || _selectedTemplate == null
|
||||
? null
|
||||
: () async {
|
||||
final validator = ValidationService();
|
||||
|
||||
// Validate coordinates
|
||||
final coordResult = validator.validateCoordinates(
|
||||
_currentPosition!.latitude,
|
||||
_currentPosition!.longitude,
|
||||
);
|
||||
if (!coordResult.isValid) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(coordResult.errorMessage!),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final notes = _notesController.text.trim();
|
||||
|
||||
// Validate notes length if provided
|
||||
final notes = _notesController.text.trim();
|
||||
if (notes.isNotEmpty) {
|
||||
final notesResult = validator.validateName(
|
||||
notes,
|
||||
@@ -642,6 +647,23 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate coordinates
|
||||
final coordResult = validator.validateCoordinates(
|
||||
_currentPosition!.latitude,
|
||||
_currentPosition!.longitude,
|
||||
);
|
||||
if (!coordResult.isValid) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(coordResult.errorMessage!),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate location accuracy (warn if >50m)
|
||||
if (_currentPosition!.accuracy != null &&
|
||||
_currentPosition!.accuracy! > 50.0) {
|
||||
@@ -669,10 +691,21 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
if (shouldContinue != true) return;
|
||||
}
|
||||
|
||||
// Combine template name with optional notes
|
||||
String displayText;
|
||||
if (notes.isNotEmpty) {
|
||||
// Include both template name and custom notes
|
||||
displayText = '${_selectedTemplate!.name} - $notes';
|
||||
} else {
|
||||
// Just the template name
|
||||
displayText = _selectedTemplate!.name;
|
||||
}
|
||||
|
||||
// Send SAR marker with emoji and display text
|
||||
await widget.onSend(
|
||||
_selectedType,
|
||||
_selectedTemplate!.emoji,
|
||||
displayText,
|
||||
_currentPosition!,
|
||||
notes.isEmpty ? null : notes,
|
||||
_selectedContact!.isChannel
|
||||
? null
|
||||
: _selectedContact!.publicKey,
|
||||
@@ -701,43 +734,29 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker Type Chip widget - Displays a selectable SAR marker type
|
||||
class MarkerTypeChip extends StatelessWidget {
|
||||
final SarMarkerType type;
|
||||
/// Template Chip widget - Displays a selectable SAR template
|
||||
class TemplateChip extends StatelessWidget {
|
||||
final SarTemplate template;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const MarkerTypeChip({
|
||||
const TemplateChip({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.template,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
Color _getMarkerColor() {
|
||||
switch (type) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return Colors.green;
|
||||
case SarMarkerType.fire:
|
||||
return Colors.red;
|
||||
case SarMarkerType.stagingArea:
|
||||
return Colors.orange;
|
||||
case SarMarkerType.object:
|
||||
return Colors.purple;
|
||||
case SarMarkerType.unknown:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _getMarkerColor();
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final color = template.color;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
@@ -758,18 +777,35 @@ class MarkerTypeChip extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
type.emoji,
|
||||
template.emoji,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
type.getLocalizedName(context),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected ? color : colorScheme.onSurface,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
template.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? color : colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (template.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
template.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
|
||||
321
lib/widgets/sar/sar_template_edit_dialog.dart
Normal file
321
lib/widgets/sar/sar_template_edit_dialog.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/sar_template.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// Dialog for adding or editing SAR templates
|
||||
class SarTemplateEditDialog extends StatefulWidget {
|
||||
final SarTemplate? template; // Null for new template
|
||||
final Function(SarTemplate) onSave;
|
||||
|
||||
const SarTemplateEditDialog({
|
||||
super.key,
|
||||
this.template,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SarTemplateEditDialog> createState() => _SarTemplateEditDialogState();
|
||||
}
|
||||
|
||||
class _SarTemplateEditDialogState extends State<SarTemplateEditDialog> {
|
||||
late TextEditingController _emojiController;
|
||||
late TextEditingController _nameController;
|
||||
late TextEditingController _descriptionController;
|
||||
late String _selectedColor;
|
||||
|
||||
final List<Map<String, dynamic>> _colorOptions = [
|
||||
{'name': 'Green', 'hex': '#4CAF50'},
|
||||
{'name': 'Red', 'hex': '#F44336'},
|
||||
{'name': 'Orange', 'hex': '#FF9800'},
|
||||
{'name': 'Purple', 'hex': '#9C27B0'},
|
||||
{'name': 'Blue', 'hex': '#2196F3'},
|
||||
{'name': 'Yellow', 'hex': '#FFC107'},
|
||||
{'name': 'Brown', 'hex': '#795548'},
|
||||
{'name': 'Gray', 'hex': '#9E9E9E'},
|
||||
];
|
||||
|
||||
String? _emojiError;
|
||||
String? _nameError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_emojiController = TextEditingController(text: widget.template?.emoji ?? '');
|
||||
_nameController = TextEditingController(text: widget.template?.name ?? '');
|
||||
_descriptionController = TextEditingController(text: widget.template?.description ?? '');
|
||||
_selectedColor = widget.template?.colorHex ?? '#4CAF50';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emojiController.dispose();
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _validate() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
setState(() {
|
||||
_emojiError = null;
|
||||
_nameError = null;
|
||||
});
|
||||
|
||||
bool isValid = true;
|
||||
|
||||
if (_emojiController.text.trim().isEmpty) {
|
||||
setState(() {
|
||||
_emojiError = l10n.emojiRequired;
|
||||
});
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (_nameController.text.trim().isEmpty) {
|
||||
setState(() {
|
||||
_nameError = l10n.nameRequired;
|
||||
});
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
void _save() {
|
||||
if (!_validate()) return;
|
||||
|
||||
final template = SarTemplate(
|
||||
id: widget.template?.id ?? 'custom_${DateTime.now().millisecondsSinceEpoch}',
|
||||
emoji: _emojiController.text.trim(),
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim(),
|
||||
colorHex: _selectedColor,
|
||||
isDefault: widget.template?.isDefault ?? false,
|
||||
);
|
||||
|
||||
widget.onSave(template);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
String _getPreview() {
|
||||
final emoji = _emojiController.text.trim();
|
||||
final description = _descriptionController.text.trim();
|
||||
if (emoji.isEmpty) return 'S::0,0';
|
||||
if (description.isEmpty) return 'S:$emoji:0,0';
|
||||
return 'S:$emoji:0,0:$description';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: DraggableScrollableSheet(
|
||||
initialChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomPadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Drag handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
Text(
|
||||
widget.template == null ? l10n.addTemplate : l10n.editTemplate,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Emoji field
|
||||
TextField(
|
||||
controller: _emojiController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.templateEmoji,
|
||||
hintText: '🧑',
|
||||
errorText: _emojiError,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.emoji_emotions),
|
||||
),
|
||||
maxLength: 4,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Name field
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.templateName,
|
||||
hintText: l10n.templateNameHint,
|
||||
errorText: _nameError,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.label),
|
||||
),
|
||||
maxLength: 30,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description field
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.templateDescription,
|
||||
hintText: l10n.templateDescriptionHint,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.description),
|
||||
),
|
||||
maxLength: 100,
|
||||
maxLines: 2,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Color picker
|
||||
Text(
|
||||
l10n.templateColor,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: _colorOptions.map((colorOption) {
|
||||
final hex = colorOption['hex'] as String;
|
||||
final color = Color(int.parse('FF${hex.replaceAll('#', '')}', radix: 16));
|
||||
final isSelected = _selectedColor == hex;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedColor = hex),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
if (isSelected)
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(Icons.check, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Preview
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.previewFormat,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_getPreview(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Actions
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save),
|
||||
label: Text(l10n.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user