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

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

View File

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

View File

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

View File

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

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