mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40: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:
@@ -71,6 +71,7 @@ abstract class MapDrawing {
|
||||
final DateTime createdAt;
|
||||
final String? senderName; // Name of sender (null if local drawing)
|
||||
final bool isReceived; // True if drawing was received from another node
|
||||
final String? messageId; // ID of the source message (for navigation)
|
||||
|
||||
MapDrawing({
|
||||
required this.id,
|
||||
@@ -79,6 +80,7 @@ abstract class MapDrawing {
|
||||
required this.createdAt,
|
||||
this.senderName,
|
||||
this.isReceived = false,
|
||||
this.messageId,
|
||||
});
|
||||
|
||||
/// Convert to JSON for persistence
|
||||
@@ -90,8 +92,12 @@ abstract class MapDrawing {
|
||||
Map<String, dynamic> toNetworkJson();
|
||||
|
||||
/// Parse network JSON (compact format)
|
||||
/// senderName will be populated from packet metadata
|
||||
static MapDrawing? fromNetworkJson(Map<String, dynamic> json, {String? senderName}) {
|
||||
/// senderName and messageId will be populated from packet metadata
|
||||
static MapDrawing? fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
final typeNum = json['t'] as int?;
|
||||
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
|
||||
return null;
|
||||
@@ -102,9 +108,17 @@ abstract class MapDrawing {
|
||||
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromNetworkJson(json, senderName: senderName);
|
||||
return LineDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromNetworkJson(json, senderName: senderName);
|
||||
return RectangleDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
@@ -144,6 +158,7 @@ class LineDrawing extends MapDrawing {
|
||||
required this.points,
|
||||
super.senderName,
|
||||
super.isReceived,
|
||||
super.messageId,
|
||||
}) : super(type: DrawingShapeType.line);
|
||||
|
||||
@override
|
||||
@@ -184,7 +199,11 @@ class LineDrawing extends MapDrawing {
|
||||
);
|
||||
}
|
||||
|
||||
static LineDrawing fromNetworkJson(Map<String, dynamic> json, {String? senderName}) {
|
||||
static LineDrawing fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
// Parse ultra-compact format
|
||||
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
|
||||
final points = <LatLng>[];
|
||||
@@ -199,6 +218,7 @@ class LineDrawing extends MapDrawing {
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId, // Link to source message
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,6 +246,7 @@ class RectangleDrawing extends MapDrawing {
|
||||
required this.bottomRight,
|
||||
super.senderName,
|
||||
super.isReceived,
|
||||
super.messageId,
|
||||
}) : super(type: DrawingShapeType.rectangle);
|
||||
|
||||
/// Get all corner points for rendering
|
||||
@@ -276,7 +297,11 @@ class RectangleDrawing extends MapDrawing {
|
||||
);
|
||||
}
|
||||
|
||||
static RectangleDrawing fromNetworkJson(Map<String, dynamic> json, {String? senderName}) {
|
||||
static RectangleDrawing fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
// Parse ultra-compact format
|
||||
final bounds = (json['b'] as List<dynamic>).cast<double>();
|
||||
|
||||
@@ -288,6 +313,7 @@ class RectangleDrawing extends MapDrawing {
|
||||
bottomRight: LatLng(bounds[2], bounds[3]),
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId, // Link to source message
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ enum MapLayerType {
|
||||
openStreetMap,
|
||||
openTopoMap,
|
||||
esriWorldImagery,
|
||||
googleHybrid,
|
||||
googleRoadmap,
|
||||
googleTerrain,
|
||||
vectorMbtiles,
|
||||
}
|
||||
|
||||
@@ -46,6 +49,12 @@ class MapLayer {
|
||||
return localizations.openTopoMap;
|
||||
case MapLayerType.esriWorldImagery:
|
||||
return localizations.esriSatellite;
|
||||
case MapLayerType.googleHybrid:
|
||||
return localizations.googleHybrid;
|
||||
case MapLayerType.googleRoadmap:
|
||||
return localizations.googleRoadmap;
|
||||
case MapLayerType.googleTerrain:
|
||||
return localizations.googleTerrain;
|
||||
case MapLayerType.vectorMbtiles:
|
||||
// For vector tiles, use the name from metadata
|
||||
return name;
|
||||
@@ -77,10 +86,37 @@ class MapLayer {
|
||||
maxZoom: 19, // ESRI World Imagery maximum
|
||||
);
|
||||
|
||||
static const googleHybrid = MapLayer(
|
||||
type: MapLayerType.googleHybrid,
|
||||
name: 'Google Hybrid',
|
||||
urlTemplate: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
maxZoom: 20, // Google Maps maximum
|
||||
);
|
||||
|
||||
static const googleRoadmap = MapLayer(
|
||||
type: MapLayerType.googleRoadmap,
|
||||
name: 'Google Roadmap',
|
||||
urlTemplate: 'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
maxZoom: 20, // Google Maps maximum
|
||||
);
|
||||
|
||||
static const googleTerrain = MapLayer(
|
||||
type: MapLayerType.googleTerrain,
|
||||
name: 'Google Terrain',
|
||||
urlTemplate: 'http://mt0.google.com/vt/lyrs=p&hl=en&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
maxZoom: 20, // Google Maps maximum
|
||||
);
|
||||
|
||||
static const List<MapLayer> allLayers = [
|
||||
openStreetMap,
|
||||
openTopoMap,
|
||||
esriWorldImagery,
|
||||
googleHybrid,
|
||||
googleRoadmap,
|
||||
googleTerrain,
|
||||
];
|
||||
|
||||
static MapLayer fromType(MapLayerType type) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'sar_marker.dart';
|
||||
|
||||
@@ -51,6 +52,7 @@ class Message {
|
||||
final SarMarkerType? sarMarkerType;
|
||||
final LatLng? sarGpsCoordinates;
|
||||
final String? sarNotes; // Optional message/notes for SAR marker
|
||||
final String? sarCustomEmoji; // Custom emoji for unknown SAR marker types
|
||||
|
||||
// Display metadata
|
||||
final DateTime receivedAt;
|
||||
@@ -89,6 +91,7 @@ class Message {
|
||||
this.sarMarkerType,
|
||||
this.sarGpsCoordinates,
|
||||
this.sarNotes,
|
||||
this.sarCustomEmoji,
|
||||
required this.receivedAt,
|
||||
this.senderName,
|
||||
this.deliveryStatus = MessageDeliveryStatus.received,
|
||||
@@ -171,6 +174,13 @@ class Message {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Debug: Check what's in sarNotes
|
||||
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
|
||||
debugPrint(' message.text: "${text}"');
|
||||
debugPrint(' message.sarNotes: "${sarNotes}"');
|
||||
debugPrint(' message.sarMarkerType: ${sarMarkerType}');
|
||||
debugPrint(' message.sarCustomEmoji: "${sarCustomEmoji}"');
|
||||
|
||||
return SarMarker(
|
||||
id: id,
|
||||
type: sarMarkerType!,
|
||||
@@ -179,6 +189,7 @@ class Message {
|
||||
senderPublicKey: senderPublicKeyPrefix,
|
||||
senderName: senderName,
|
||||
notes: sarNotes, // Use dedicated notes field instead of full text
|
||||
customEmoji: sarCustomEmoji, // Preserve custom emoji for unknown types
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,6 +286,7 @@ class Message {
|
||||
SarMarkerType? sarMarkerType,
|
||||
LatLng? sarGpsCoordinates,
|
||||
String? sarNotes,
|
||||
String? sarCustomEmoji,
|
||||
DateTime? receivedAt,
|
||||
String? senderName,
|
||||
MessageDeliveryStatus? deliveryStatus,
|
||||
@@ -303,6 +315,7 @@ class Message {
|
||||
sarMarkerType: sarMarkerType ?? this.sarMarkerType,
|
||||
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
|
||||
sarNotes: sarNotes ?? this.sarNotes,
|
||||
sarCustomEmoji: sarCustomEmoji ?? this.sarCustomEmoji,
|
||||
receivedAt: receivedAt ?? this.receivedAt,
|
||||
senderName: senderName ?? this.senderName,
|
||||
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/sar_template_service.dart';
|
||||
|
||||
/// SAR (Search & Rescue) marker types
|
||||
enum SarMarkerType {
|
||||
@@ -75,6 +76,7 @@ class SarMarker {
|
||||
final Uint8List? senderPublicKey;
|
||||
final String? senderName;
|
||||
final String? notes;
|
||||
final String? customEmoji; // For custom SAR markers not in predefined types
|
||||
|
||||
SarMarker({
|
||||
required this.id,
|
||||
@@ -84,6 +86,7 @@ class SarMarker {
|
||||
this.senderPublicKey,
|
||||
this.senderName,
|
||||
this.notes,
|
||||
this.customEmoji,
|
||||
});
|
||||
|
||||
/// Get sender public key as hex string (short)
|
||||
@@ -109,9 +112,47 @@ class SarMarker {
|
||||
return DateTime.now().difference(timestamp).inHours < 1;
|
||||
}
|
||||
|
||||
/// Get display name
|
||||
/// Get the emoji to display (custom emoji if available, otherwise type emoji)
|
||||
String get emoji {
|
||||
return customEmoji ?? type.emoji;
|
||||
}
|
||||
|
||||
/// Get display name - uses notes if available, otherwise looks up template by emoji, otherwise type name
|
||||
String get displayName {
|
||||
return '${type.emoji} ${type.displayName}';
|
||||
if (notes != null && notes!.isNotEmpty) {
|
||||
return notes!;
|
||||
}
|
||||
|
||||
// If no notes and we have a custom emoji, try to look up the template
|
||||
if (customEmoji != null) {
|
||||
// Import the service here to avoid circular dependencies
|
||||
// We'll use a static lookup method
|
||||
return _lookupTemplateNameByEmoji(customEmoji!) ?? type.displayName;
|
||||
}
|
||||
|
||||
return type.displayName;
|
||||
}
|
||||
|
||||
/// Look up template name by emoji from SarTemplateService
|
||||
static String? _lookupTemplateNameByEmoji(String emoji) {
|
||||
try {
|
||||
// Use the singleton instance
|
||||
final service = SarTemplateService();
|
||||
if (!service.isInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find template with matching emoji
|
||||
final template = service.templates.firstWhere(
|
||||
(t) => t.emoji == emoji,
|
||||
orElse: () => throw StateError('No template found'),
|
||||
);
|
||||
|
||||
return template.name;
|
||||
} catch (e) {
|
||||
// Template not found or service not initialized
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
SarMarker copyWith({
|
||||
@@ -122,6 +163,7 @@ class SarMarker {
|
||||
Uint8List? senderPublicKey,
|
||||
String? senderName,
|
||||
String? notes,
|
||||
String? customEmoji,
|
||||
}) {
|
||||
return SarMarker(
|
||||
id: id ?? this.id,
|
||||
@@ -131,6 +173,7 @@ class SarMarker {
|
||||
senderPublicKey: senderPublicKey ?? this.senderPublicKey,
|
||||
senderName: senderName ?? this.senderName,
|
||||
notes: notes ?? this.notes,
|
||||
customEmoji: customEmoji ?? this.customEmoji,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
196
lib/models/sar_template.dart
Normal file
196
lib/models/sar_template.dart
Normal file
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// SAR Template - Customizable template for SAR (Cursor on Target) messages
|
||||
class SarTemplate {
|
||||
final String id;
|
||||
final String emoji;
|
||||
final String name;
|
||||
final String description;
|
||||
final String colorHex;
|
||||
final bool isDefault;
|
||||
|
||||
SarTemplate({
|
||||
required this.id,
|
||||
required this.emoji,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.colorHex,
|
||||
this.isDefault = false,
|
||||
});
|
||||
|
||||
/// Get color from hex string
|
||||
Color get color {
|
||||
final hexCode = colorHex.replaceAll('#', '');
|
||||
return Color(int.parse('FF$hexCode', radix: 16));
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
factory SarTemplate.fromJson(Map<String, dynamic> json) {
|
||||
return SarTemplate(
|
||||
id: json['id'] as String,
|
||||
emoji: json['emoji'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
colorHex: json['colorHex'] as String,
|
||||
isDefault: json['isDefault'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'emoji': emoji,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'colorHex': colorHex,
|
||||
'isDefault': isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from SAR message format (S:emoji:0,0:description)
|
||||
/// Example: S:🧑:0,0:Person found
|
||||
factory SarTemplate.fromSarMessage(String message) {
|
||||
final trimmed = message.trim();
|
||||
if (!trimmed.startsWith('S:')) {
|
||||
throw FormatException('SAR message must start with "S:"');
|
||||
}
|
||||
|
||||
// Parse format: S:emoji:lat,lon:description
|
||||
final parts = trimmed.split(':');
|
||||
if (parts.length < 3) {
|
||||
throw FormatException('Invalid SAR message format');
|
||||
}
|
||||
|
||||
final emoji = parts[1].trim();
|
||||
if (emoji.isEmpty) {
|
||||
throw FormatException('Emoji cannot be empty');
|
||||
}
|
||||
|
||||
// Extract description (everything after the third colon)
|
||||
String description = '';
|
||||
if (parts.length > 3) {
|
||||
description = parts.sublist(3).join(':').trim();
|
||||
}
|
||||
|
||||
// Generate ID from emoji + description
|
||||
final id = '${emoji}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
// Auto-assign color based on emoji
|
||||
String colorHex = _getColorForEmoji(emoji);
|
||||
|
||||
return SarTemplate(
|
||||
id: id,
|
||||
emoji: emoji,
|
||||
name: description.isNotEmpty ? description : emoji,
|
||||
description: description,
|
||||
colorHex: colorHex,
|
||||
isDefault: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to SAR message format with placeholder coordinates
|
||||
/// Example: S:🧑:0,0:Person found
|
||||
String toSarMessage() {
|
||||
if (description.isNotEmpty) {
|
||||
return 'S:$emoji:0,0:$description';
|
||||
}
|
||||
return 'S:$emoji:0,0';
|
||||
}
|
||||
|
||||
/// Auto-assign color based on emoji
|
||||
static String _getColorForEmoji(String emoji) {
|
||||
// Default emoji to color mapping
|
||||
final colorMap = {
|
||||
'🧑': '#4CAF50', // Green - Person
|
||||
'👤': '#4CAF50', // Green - Person
|
||||
'🔥': '#F44336', // Red - Fire
|
||||
'🏕️': '#FF9800', // Orange - Staging
|
||||
'⛺': '#FF9800', // Orange - Staging
|
||||
'📦': '#9C27B0', // Purple - Object
|
||||
'🚁': '#2196F3', // Blue - Helicopter
|
||||
'🚒': '#F44336', // Red - Fire truck
|
||||
'🚑': '#F44336', // Red - Ambulance
|
||||
'⚠️': '#FFC107', // Yellow - Warning
|
||||
'❌': '#F44336', // Red - Hazard
|
||||
'✅': '#4CAF50', // Green - Safe
|
||||
'🏥': '#F44336', // Red - Medical
|
||||
'💧': '#2196F3', // Blue - Water
|
||||
'🌲': '#4CAF50', // Green - Forest
|
||||
'⛰️': '#795548', // Brown - Mountain
|
||||
};
|
||||
|
||||
return colorMap[emoji] ?? '#9E9E9E'; // Default gray
|
||||
}
|
||||
|
||||
/// Copy with modifications
|
||||
SarTemplate copyWith({
|
||||
String? id,
|
||||
String? emoji,
|
||||
String? name,
|
||||
String? description,
|
||||
String? colorHex,
|
||||
bool? isDefault,
|
||||
}) {
|
||||
return SarTemplate(
|
||||
id: id ?? this.id,
|
||||
emoji: emoji ?? this.emoji,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
colorHex: colorHex ?? this.colorHex,
|
||||
isDefault: isDefault ?? this.isDefault,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SarTemplate(id: $id, emoji: $emoji, name: $name, description: $description)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is SarTemplate && id == other.id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
/// Default templates (matches existing SarMarkerType)
|
||||
static List<SarTemplate> get defaults {
|
||||
return [
|
||||
SarTemplate(
|
||||
id: 'default_found_person',
|
||||
emoji: '🧑',
|
||||
name: 'Found Person',
|
||||
description: '',
|
||||
colorHex: '#4CAF50',
|
||||
isDefault: true,
|
||||
),
|
||||
SarTemplate(
|
||||
id: 'default_fire',
|
||||
emoji: '🔥',
|
||||
name: 'Fire',
|
||||
description: '',
|
||||
colorHex: '#F44336',
|
||||
isDefault: true,
|
||||
),
|
||||
SarTemplate(
|
||||
id: 'default_staging_area',
|
||||
emoji: '🏕️',
|
||||
name: 'Staging Area',
|
||||
description: '',
|
||||
colorHex: '#FF9800',
|
||||
isDefault: true,
|
||||
),
|
||||
SarTemplate(
|
||||
id: 'default_object',
|
||||
emoji: '📦',
|
||||
name: 'Object',
|
||||
description: '',
|
||||
colorHex: '#9C27B0',
|
||||
isDefault: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user