feat: Enhance SAR message handling with improved parsing and object marker generation

This commit is contained in:
Janez T
2025-10-14 20:10:10 +02:00
parent 13df47b297
commit 3bcf3fc59f
7 changed files with 267 additions and 59 deletions

View File

@@ -15,7 +15,9 @@
"Bash(nc:*)", "Bash(nc:*)",
"Bash(pkill:*)", "Bash(pkill:*)",
"WebFetch(domain:github.com)", "WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)" "WebFetch(domain:raw.githubusercontent.com)",
"Bash(dart run:*)",
"Bash(dart test_sar_debug.dart:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -2,6 +2,16 @@
This document provides technical details for AI assistants (like Claude) working with this codebase. This document provides technical details for AI assistants (like Claude) working with this codebase.
## ⚠️ IMPORTANT: Flutter Development Rules
**NEVER run or kill Flutter processes:**
- **DO NOT** execute `flutter run` command
- **DO NOT** kill Flutter processes (e.g., `pkill flutter`, `killall flutter`)
- The user manages the Flutter development server themselves
- Only make code changes and let the user trigger hot reload manually
**Hot reload happens automatically when you save files** - the user has their own Flutter process running and will see changes instantly.
## Project Overview ## Project Overview
**Type**: Flutter Mobile Application **Type**: Flutter Mobile Application

View File

@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../services/message_storage_service.dart'; import '../services/message_storage_service.dart';
import '../utils/sar_message_parser.dart';
/// Messages Provider - manages message history and SAR markers /// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
@@ -45,13 +46,16 @@ class MessagesProvider with ChangeNotifier {
print('📦 [MessagesProvider] Loading persisted messages...'); print('📦 [MessagesProvider] Loading persisted messages...');
final storedMessages = await _storageService.loadMessages(); final storedMessages = await _storageService.loadMessages();
// Add stored messages // Add stored messages with enhancement to ensure SAR detection
_messages.addAll(storedMessages);
// Extract SAR markers from stored messages
for (final message in storedMessages) { for (final message in storedMessages) {
if (message.isSarMarker) { // Re-enhance each message to ensure SAR markers are properly detected
final marker = message.toSarMarker(); // This handles cases where messages were stored before enhancement logic
final enhancedMessage = SarMessageParser.enhanceMessage(message);
_messages.add(enhancedMessage);
// Extract SAR markers
if (enhancedMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker();
if (marker != null) { if (marker != null) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
} }
@@ -69,11 +73,21 @@ class MessagesProvider with ChangeNotifier {
/// Add a message /// Add a message
void addMessage(Message message) { void addMessage(Message message) {
_messages.add(message); // Always enhance message with SAR parser to detect SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message);
// Debug: Check if message is SAR
if (message.text.startsWith('S:')) {
print('🔍 [MessagesProvider] Processing SAR message: ${message.text}');
print(' isSarMarker: ${enhancedMessage.isSarMarker}');
print(' sarMarkerType: ${enhancedMessage.sarMarkerType}');
}
_messages.add(enhancedMessage);
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (message.isSarMarker) { if (enhancedMessage.isSarMarker) {
final marker = message.toSarMarker(); final marker = enhancedMessage.toSarMarker();
if (marker != null) { if (marker != null) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
} }
@@ -88,10 +102,12 @@ class MessagesProvider with ChangeNotifier {
/// Add multiple messages /// Add multiple messages
void addMessages(List<Message> messages) { void addMessages(List<Message> messages) {
for (final message in messages) { for (final message in messages) {
_messages.add(message); // Always enhance message with SAR parser to detect SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message);
_messages.add(enhancedMessage);
if (message.isSarMarker) { if (enhancedMessage.isSarMarker) {
final marker = message.toSarMarker(); final marker = enhancedMessage.toSarMarker();
if (marker != null) { if (marker != null) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
} }

View File

@@ -572,23 +572,41 @@ class _MessageBubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isSarMarker = message.isSarMarker; final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
// Debug: Log message details
if (message.text.startsWith('S:')) {
debugPrint('🎨 [MessageBubble] Rendering SAR message:');
debugPrint(' Text: ${message.text}');
debugPrint(' isSarMarker: $isSarMarker');
debugPrint(' sarMarkerType: ${message.sarMarkerType}');
}
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSarMarker color: isSarMarker
? _getSarMarkerColor(context) ? _getSarMarkerColor(context, isDarkMode)
: Theme.of(context).colorScheme.surfaceVariant, : Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: isSarMarker border: isSarMarker
? Border.all( ? Border.all(
color: Theme.of(context).colorScheme.primary, color: _getSarMarkerBorderColor(context, isDarkMode),
width: 2, width: 3,
) )
: null, : null,
boxShadow: isSarMarker
? [
BoxShadow(
color: _getSarMarkerBorderColor(context, isDarkMode).withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -596,46 +614,61 @@ class _MessageBubble extends StatelessWidget {
// Header: Sender and time // Header: Sender and time
Row( Row(
children: [ children: [
if (message.isChannelMessage)
const Icon(Icons.tag, size: 16)
else
const Icon(Icons.person, size: 16),
const SizedBox(width: 4),
Text(
message.displaySender,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (isSarMarker) if (isSarMarker)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 10,
vertical: 2, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary, color: _getSarMarkerBorderColor(context, isDarkMode),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(6),
), ),
child: Text( child: Row(
'SAR', mainAxisSize: MainAxisSize.min,
style: Theme.of(context).textTheme.labelSmall?.copyWith( children: [
color: Theme.of(context).colorScheme.onPrimary, const Icon(
fontWeight: FontWeight.bold, Icons.warning_amber_rounded,
), size: 16,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
'SAR ALERT',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
],
), ),
)
else ...[
if (message.isChannelMessage)
const Icon(Icons.tag, size: 16)
else
const Icon(Icons.person, size: 16),
const SizedBox(width: 4),
Text(
message.displaySender,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(width: 8), ],
const Spacer(),
Text( Text(
message.timeAgo, message.timeAgo,
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: isSarMarker ? FontWeight.w600 : FontWeight.normal,
),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 12),
// SAR marker content // SAR marker content (simplified design matching message history)
if (isSarMarker && message.sarMarkerType != null) ...[ if (isSarMarker && message.sarMarkerType != null) ...[
Row( Row(
children: [ children: [
@@ -650,15 +683,16 @@ class _MessageBubble extends StatelessWidget {
children: [ children: [
Text( Text(
message.sarMarkerType!.displayName, message.sarMarkerType!.displayName,
style: style: Theme.of(context).textTheme.titleMedium?.copyWith(
Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, ),
),
), ),
if (message.sarGpsCoordinates != null) if (message.sarGpsCoordinates != null)
Text( Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
), ),
], ],
), ),
@@ -690,8 +724,88 @@ class _MessageBubble extends StatelessWidget {
); );
} }
Color _getSarMarkerColor(BuildContext context) { Color _getSarMarkerColor(BuildContext context, bool isDarkMode) {
return Theme.of(context).colorScheme.primaryContainer; if (message.sarMarkerType == null) {
return Theme.of(context).colorScheme.primaryContainer;
}
// Use type-specific colors with alpha for background
switch (message.sarMarkerType!) {
case SarMarkerType.foundPerson:
return isDarkMode
? const Color(0xFF1B5E20).withValues(alpha: 0.4) // Dark green
: const Color(0xFFC8E6C9).withValues(alpha: 0.9); // Light green
case SarMarkerType.fire:
return isDarkMode
? const Color(0xFFB71C1C).withValues(alpha: 0.4) // Dark red
: const Color(0xFFFFCDD2).withValues(alpha: 0.9); // Light red
case SarMarkerType.stagingArea:
return isDarkMode
? const Color(0xFF0D47A1).withValues(alpha: 0.4) // Dark blue
: const Color(0xFFBBDEFB).withValues(alpha: 0.9); // Light blue
case SarMarkerType.object:
return isDarkMode
? const Color(0xFF4A148C).withValues(alpha: 0.4) // Dark purple
: const Color(0xFFE1BEE7).withValues(alpha: 0.9); // Light purple
case SarMarkerType.unknown:
return isDarkMode
? const Color(0xFF424242).withValues(alpha: 0.4) // Dark gray
: const Color(0xFFEEEEEE).withValues(alpha: 0.9); // Light gray
}
}
Color _getSarMarkerBorderColor(BuildContext context, bool isDarkMode) {
if (message.sarMarkerType == null) {
return Theme.of(context).colorScheme.primary;
}
// Use vibrant type-specific colors for borders
switch (message.sarMarkerType!) {
case SarMarkerType.foundPerson:
return const Color(0xFF4CAF50); // Green
case SarMarkerType.fire:
return const Color(0xFFF44336); // Red
case SarMarkerType.stagingArea:
return const Color(0xFF2196F3); // Blue
case SarMarkerType.object:
return const Color(0xFF9C27B0); // Purple
case SarMarkerType.unknown:
return const Color(0xFF9E9E9E); // Gray
}
}
/// Extract notes from SAR message text
/// Returns text after the SAR marker format, or null if none
String? _extractNotesFromMessage(String text) {
final trimmed = text.trim();
if (!trimmed.startsWith('S:')) return null;
// Extract first line
final firstLine = trimmed.split('\n').first;
// Find the end of coordinates (after second colon and comma-separated numbers)
final pattern = RegExp(r'^S:.:(-?\d+\.?\d*),(-?\d+\.?\d*)');
final match = pattern.firstMatch(firstLine);
if (match == null) return null;
// Extract notes from same line
String? notes;
if (match.end < firstLine.length) {
notes = firstLine.substring(match.end).trim();
}
// Check for multi-line notes
final lines = trimmed.split('\n');
if (lines.length > 1) {
final additionalNotes = lines.sublist(1).join('\n').trim();
if (additionalNotes.isNotEmpty) {
notes = notes != null && notes.isNotEmpty
? '$notes\n$additionalNotes'
: additionalNotes;
}
}
return notes != null && notes.isNotEmpty ? notes : null;
} }
} }

View File

@@ -134,9 +134,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
foundPersonCount: 2, foundPersonCount: 2,
fireCount: 1, fireCount: 1,
stagingCount: 1, stagingCount: 1,
objectCount: 1,
); );
final channelMessages = SampleDataGenerator.generateChannelMessages( final channelMessages = SampleDataGenerator.generateChannelMessages(
centerLocation: centerLocation,
generalChannelMessages: 8, generalChannelMessages: 8,
emergencyChannelMessages: 5, emergencyChannelMessages: 5,
); );

View File

@@ -117,6 +117,7 @@ class SampleDataGenerator {
int foundPersonCount = 2, int foundPersonCount = 2,
int fireCount = 1, int fireCount = 1,
int stagingCount = 1, int stagingCount = 1,
int objectCount = 1,
}) { }) {
final messages = <Message>[]; final messages = <Message>[];
final now = DateTime.now(); final now = DateTime.now();
@@ -209,14 +210,53 @@ class SampleDataGenerator {
messageId++; messageId++;
} }
// Generate object markers
for (int i = 0; i < objectCount; i++) {
final latOffset = (_random.nextDouble() - 0.5) * 0.015;
final lonOffset = (_random.nextDouble() - 0.5) * 0.015;
final lat = centerLocation.latitude + latOffset;
final lon = centerLocation.longitude + lonOffset;
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
final timestamp = now.subtract(Duration(minutes: 40 + i * 5));
final notes = [
' Backpack found - blue color',
' Vehicle abandoned - check for owner',
' Camping equipment discovered',
' Trail marker found off-path',
];
messages.add(Message(
id: 'sample_object_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: 'S:📦:${lat.toStringAsFixed(4)},${lon.toStringAsFixed(4)}${notes[i % notes.length]}',
receivedAt: timestamp,
isSarMarker: true,
sarMarkerType: SarMarkerType.object,
sarGpsCoordinates: LatLng(lat, lon),
senderName: 'Sample Searcher',
));
messageId++;
}
return messages; return messages;
} }
/// Generate sample channel messages for public channels /// Generate sample channel messages for public channels
static List<Message> generateChannelMessages({ static List<Message> generateChannelMessages({
LatLng? centerLocation,
int generalChannelMessages = 8, int generalChannelMessages = 8,
int emergencyChannelMessages = 5, int emergencyChannelMessages = 5,
}) { }) {
// Use provided location or default to Ljubljana, Slovenia
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
final messages = <Message>[]; final messages = <Message>[];
final now = DateTime.now(); final now = DateTime.now();
int messageId = 1000; // Start with high ID to avoid conflicts int messageId = 1000; // Start with high ID to avoid conflicts
@@ -238,16 +278,17 @@ class SampleDataGenerator {
]; ];
// Sample messages for Emergency channel (index 1) // Sample messages for Emergency channel (index 1)
// Mix regular messages and SAR markers
final emergencyMessages = [ final emergencyMessages = [
'URGENT: Medical assistance needed at sector 4', 'URGENT: Medical assistance needed at sector 4',
'Found person - requesting immediate evac', 'S:🧑:${center.latitude.toStringAsFixed(4)},${(center.longitude + 0.005).toStringAsFixed(4)} Adult male, conscious',
'Fire spotted - coordinates incoming', 'Fire spotted - coordinates incoming',
'Team member injured - sending location', 'S:🔥:${(center.latitude + 0.008).toStringAsFixed(4)},${(center.longitude + 0.003).toStringAsFixed(4)} Spreading rapidly!',
'PRIORITY: Need helicopter support', 'PRIORITY: Need helicopter support',
'Medical team en route to your location', 'Medical team en route to your location',
'Evac helicopter ETA 10 minutes', 'Evac helicopter ETA 10 minutes',
'Emergency resolved - all clear', 'Emergency resolved - all clear',
'Casualties: 1 minor injury, being treated', 'S:🏕️:${(center.latitude - 0.002).toStringAsFixed(4)},${(center.longitude - 0.004).toStringAsFixed(4)} Emergency staging area',
'Emergency services notified and responding', 'Emergency services notified and responding',
]; ];

View File

@@ -9,14 +9,19 @@ import '../models/message.dart';
/// S:🔥:40.7128,-74.0060 /// S:🔥:40.7128,-74.0060
/// S:🏕️:34.0522,-118.2437 /// S:🏕️:34.0522,-118.2437
class SarMessageParser { class SarMessageParser {
// Updated regex to allow optional notes after coordinates
// Captures: emoji (one or more non-colon chars), latitude, longitude
// Note: Emojis are multi-byte characters, so we use [^:]+ instead of .
static final RegExp _sarPattern = RegExp( static final RegExp _sarPattern = RegExp(
r'^S:(.):(-?\d+\.?\d*),(-?\d+\.?\d*)$', r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*)',
multiLine: false, multiLine: false,
); );
/// Check if a message is a SAR marker message /// Check if a message is a SAR marker message
static bool isSarMessage(String text) { static bool isSarMessage(String text) {
return text.trim().startsWith('S:') && _sarPattern.hasMatch(text.trim()); // Extract just the first line for matching
final firstLine = text.trim().split('\n').first;
return firstLine.startsWith('S:') && _sarPattern.hasMatch(firstLine);
} }
/// Parse a SAR message and extract marker information /// Parse a SAR message and extract marker information
@@ -25,7 +30,9 @@ class SarMessageParser {
final trimmed = text.trim(); final trimmed = text.trim();
if (!trimmed.startsWith('S:')) return null; if (!trimmed.startsWith('S:')) return null;
final match = _sarPattern.firstMatch(trimmed); // Extract first line (actual SAR marker)
final firstLine = trimmed.split('\n').first;
final match = _sarPattern.firstMatch(firstLine);
if (match == null) return null; if (match == null) return null;
try { try {
@@ -40,10 +47,24 @@ class SarMessageParser {
final markerType = SarMarkerType.fromEmoji(emoji); final markerType = SarMarkerType.fromEmoji(emoji);
final location = LatLng(latitude, longitude); final location = LatLng(latitude, longitude);
// Extract notes if present (everything after coordinates on first line, or subsequent lines)
String? notes;
final coordsEnd = match.end;
if (coordsEnd < firstLine.length) {
// Notes on same line after coordinates
notes = firstLine.substring(coordsEnd).trim();
}
// Check for multi-line notes
final additionalNotes = extractNotes(text);
if (additionalNotes != null) {
notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes;
}
return SarMarkerInfo( return SarMarkerInfo(
type: markerType, type: markerType,
location: location, location: location,
emoji: emoji, emoji: emoji,
notes: notes,
); );
} catch (e) { } catch (e) {
return null; return null;
@@ -139,15 +160,17 @@ class SarMarkerInfo {
final SarMarkerType type; final SarMarkerType type;
final LatLng location; final LatLng location;
final String emoji; final String emoji;
final String? notes;
SarMarkerInfo({ SarMarkerInfo({
required this.type, required this.type,
required this.location, required this.location,
required this.emoji, required this.emoji,
this.notes,
}); });
@override @override
String toString() { String toString() {
return 'SarMarkerInfo(type: ${type.displayName}, location: $location)'; return 'SarMarkerInfo(type: ${type.displayName}, location: $location, notes: $notes)';
} }
} }