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:
Janez T
2025-10-21 23:44:49 +02:00
parent e9d516b749
commit 021ce21cbe
60 changed files with 8736 additions and 1413 deletions

View 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,
);
}
}

View File

@@ -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)