Add custom cave map mode

This commit is contained in:
Janez T
2026-03-16 10:50:44 +01:00
parent c82e0c3452
commit fedd7b9a2b
23 changed files with 2948 additions and 1058 deletions

View File

@@ -0,0 +1,462 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../models/sar_template.dart';
import '../../providers/contacts_provider.dart';
import '../../services/sar_template_service.dart';
import 'sar_update_sheet.dart' show TemplateChip;
class CustomMapSarUpdateSheet extends StatefulWidget {
final String mapName;
final String mapId;
final String pointLabel;
final Future<void> Function(
String emoji,
String name,
Uint8List? roomPublicKey,
bool sendToChannel,
bool sendToAllContacts,
int colorIndex,
)
onSend;
const CustomMapSarUpdateSheet({
super.key,
required this.mapName,
required this.mapId,
required this.pointLabel,
required this.onSend,
});
@override
State<CustomMapSarUpdateSheet> createState() =>
_CustomMapSarUpdateSheetState();
}
class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
final SarTemplateService _templateService = SarTemplateService();
final TextEditingController _notesController = TextEditingController();
List<SarTemplate> _templates = [];
SarTemplate? _selectedTemplate;
Contact? _selectedContact;
bool _sendToAllContacts = false;
@override
void initState() {
super.initState();
_initializeTemplates();
_setDefaultDestination();
}
Future<void> _initializeTemplates() async {
if (!_templateService.isInitialized) {
await _templateService.initialize();
}
if (!mounted) return;
setState(() {
_templates = _templateService.templates;
if (_templates.isNotEmpty) {
_selectedTemplate = _templates.first;
}
});
}
void _setDefaultDestination() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final contactsProvider = context.read<ContactsProvider>();
final roomsAndChannels = contactsProvider.roomsAndChannels;
final teamContacts = contactsProvider.chatContacts;
if (teamContacts.length > 1) {
setState(() {
_sendToAllContacts = true;
_selectedContact = null;
});
return;
}
if (teamContacts.length == 1) {
setState(() {
_sendToAllContacts = false;
_selectedContact = teamContacts.first;
});
return;
}
if (roomsAndChannels.any((c) => c.isRoom)) {
setState(() {
_sendToAllContacts = false;
_selectedContact = roomsAndChannels.firstWhere((c) => c.isRoom);
});
return;
}
if (roomsAndChannels.isNotEmpty) {
setState(() {
_sendToAllContacts = false;
_selectedContact = roomsAndChannels.first;
});
}
});
}
@override
void dispose() {
_notesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return AnimatedPadding(
padding: EdgeInsets.only(bottom: keyboardHeight),
duration: const Duration(milliseconds: 100),
child: Container(
height: MediaQuery.of(context).size.height * 0.88,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
Text(
'Send SAR marker',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'Custom cave map point',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 48),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 80 + bottomSafeArea,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.markerType,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
..._templates.map((template) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TemplateChip(
template: template,
isSelected: _selectedTemplate?.id == template.id,
onTap: () =>
setState(() => _selectedTemplate = template),
),
);
}),
const SizedBox(height: 24),
Text(
AppLocalizations.of(context)!.sendTo,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final teamContacts = contactsProvider.chatContacts;
final roomsAndChannels =
contactsProvider.roomsAndChannels;
final destinations = <Contact>[
...teamContacts,
...roomsAndChannels.where((c) => c.isRoom),
...roomsAndChannels.where((c) => c.isChannel),
];
if (destinations.isEmpty && teamContacts.isEmpty) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.red.withValues(alpha: 0.3),
),
),
child: const Text('No destinations available'),
);
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.3),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _sendToAllContacts
? 'all_contacts'
: _selectedContact?.publicKeyHex,
hint: Text(
AppLocalizations.of(context)!.selectDestination,
),
dropdownColor:
colorScheme.surfaceContainerHighest,
isExpanded: true,
items: [
if (teamContacts.isNotEmpty)
DropdownMenuItem<String>(
value: 'all_contacts',
child: Row(
children: [
const Icon(Icons.group, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
AppLocalizations.of(
context,
)!.allTeamContacts,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
...destinations.map((contact) {
final icon = contact.isChat
? Icons.person
: contact.isRoom
? Icons.storage
: Icons.public;
return DropdownMenuItem<String>(
value: contact.publicKeyHex,
child: Row(
children: [
Icon(icon, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
contact.getLocalizedDisplayName(
context,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}),
],
onChanged: (value) {
setState(() {
if (value == 'all_contacts') {
_sendToAllContacts = true;
_selectedContact = null;
} else {
_sendToAllContacts = false;
_selectedContact = destinations.firstWhere(
(c) => c.publicKeyHex == value,
);
}
});
},
),
),
);
},
),
const SizedBox(height: 24),
Text(
'Map point',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.map_outlined,
size: 20,
color: Colors.blue,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.mapName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
Text(
widget.pointLabel,
style: TextStyle(
color: colorScheme.onSurface,
fontFamily: 'monospace',
fontSize: 13,
),
),
const SizedBox(height: 4),
Text(
'Map ID: ${widget.mapId}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 24),
Text(
'Additional notes (optional)',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Add additional details',
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.all(16),
),
),
],
),
),
),
Container(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomSafeArea),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.2),
),
),
),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
if (_selectedTemplate == null ||
(!_sendToAllContacts && _selectedContact == null)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Select marker type and destination'),
),
);
return;
}
await widget.onSend(
_selectedTemplate!.emoji,
_notesController.text.trim().isEmpty
? _selectedTemplate!.name
: _notesController.text.trim(),
_selectedContact?.isChannel == true
? null
: _selectedContact?.publicKey,
_selectedContact?.isChannel == true,
_sendToAllContacts,
_templates.indexOf(_selectedTemplate!),
);
if (context.mounted) {
Navigator.pop(context);
}
},
icon: const Icon(Icons.send),
label: const Text('Send'),
),
),
),
],
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
@@ -7,6 +8,7 @@ import '../../models/message.dart';
import '../../models/sar_marker.dart';
import '../../models/sar_template.dart';
import '../../models/map_drawing.dart';
import '../../models/map_coordinate_space.dart';
import '../../providers/messages_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart';
@@ -1490,7 +1492,7 @@ class _MessageBubbleState extends State<MessageBubble> {
void _navigateToDrawing(BuildContext context) {
if (widget.message.drawingId == null) return;
widget.onNavigateToMap?.call();
widget.onTap?.call();
}
void _copyDrawingCoordinates(BuildContext context) {
@@ -1505,21 +1507,18 @@ class _MessageBubbleState extends State<MessageBubble> {
}
// Format coordinates based on drawing type
String formatPoint(LatLng point) {
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
return '${point.latitude.toStringAsFixed(0)}, ${point.longitude.toStringAsFixed(0)}';
}
return '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}';
}
String coordinatesText;
if (drawing is LineDrawing) {
coordinatesText = drawing.points
.map(
(p) =>
'${p.latitude.toStringAsFixed(5)}, ${p.longitude.toStringAsFixed(5)}',
)
.join('\n');
coordinatesText = drawing.points.map(formatPoint).join('\n');
} else if (drawing is RectangleDrawing) {
coordinatesText = drawing.corners
.map(
(p) =>
'${p.latitude.toStringAsFixed(5)}, ${p.longitude.toStringAsFixed(5)}',
)
.join('\n');
coordinatesText = drawing.corners.map(formatPoint).join('\n');
} else {
ToastLogger.error(context, 'Unknown drawing type');
return;
@@ -2294,6 +2293,72 @@ class _MessageBubbleState extends State<MessageBubble> {
],
),
),
] else if (message.sarCustomMapPoint != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: isDarkMode ? 0.18 : 0.05,
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.map_outlined,
size: 15,
color: _getSarMarkerBorderColor(
context,
isDarkMode,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Custom map marker',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 6),
Text(
'Point: ${message.sarCustomMapPoint!.latitude.toStringAsFixed(0)}, ${message.sarCustomMapPoint!.longitude.toStringAsFixed(0)}',
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
),
),
if (message.sarCustomMapId != null &&
message.sarCustomMapId!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'Map ID: ${message.sarCustomMapId}',
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
),
),
],
],
),
),
],
],
),