From 3bcf3fc59f4380772b8f7c128a4ebf579c09aa68 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 20:10:10 +0200 Subject: [PATCH] feat: Enhance SAR message handling with improved parsing and object marker generation --- .claude/settings.local.json | 4 +- CLAUDE.md | 10 ++ lib/providers/messages_provider.dart | 40 ++++-- lib/screens/messages_tab.dart | 192 +++++++++++++++++++++------ lib/screens/settings_screen.dart | 2 + lib/utils/sample_data_generator.dart | 47 ++++++- lib/utils/sar_message_parser.dart | 31 ++++- 7 files changed, 267 insertions(+), 59 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c29312b..039d446 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,9 @@ "Bash(nc:*)", "Bash(pkill:*)", "WebFetch(domain:github.com)", - "WebFetch(domain:raw.githubusercontent.com)" + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(dart run:*)", + "Bash(dart test_sar_debug.dart:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md index e8e7aa4..9508f96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,16 @@ 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 **Type**: Flutter Mobile Application diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 08c6575..37975d0 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import '../models/message.dart'; import '../models/sar_marker.dart'; import '../services/message_storage_service.dart'; +import '../utils/sar_message_parser.dart'; /// Messages Provider - manages message history and SAR markers class MessagesProvider with ChangeNotifier { @@ -45,13 +46,16 @@ class MessagesProvider with ChangeNotifier { print('📦 [MessagesProvider] Loading persisted messages...'); final storedMessages = await _storageService.loadMessages(); - // Add stored messages - _messages.addAll(storedMessages); - - // Extract SAR markers from stored messages + // Add stored messages with enhancement to ensure SAR detection for (final message in storedMessages) { - if (message.isSarMarker) { - final marker = message.toSarMarker(); + // Re-enhance each message to ensure SAR markers are properly detected + // 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) { _sarMarkers[marker.id] = marker; } @@ -69,11 +73,21 @@ class MessagesProvider with ChangeNotifier { /// Add a 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 (message.isSarMarker) { - final marker = message.toSarMarker(); + if (enhancedMessage.isSarMarker) { + final marker = enhancedMessage.toSarMarker(); if (marker != null) { _sarMarkers[marker.id] = marker; } @@ -88,10 +102,12 @@ class MessagesProvider with ChangeNotifier { /// Add multiple messages void addMessages(List 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) { - final marker = message.toSarMarker(); + if (enhancedMessage.isSarMarker) { + final marker = enhancedMessage.toSarMarker(); if (marker != null) { _sarMarkers[marker.id] = marker; } diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 4eb2483..ee1c55c 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -572,23 +572,41 @@ class _MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { 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( onTap: onTap, child: Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: isSarMarker - ? _getSarMarkerColor(context) + ? _getSarMarkerColor(context, isDarkMode) : Theme.of(context).colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), border: isSarMarker ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, + color: _getSarMarkerBorderColor(context, isDarkMode), + width: 3, ) : null, + boxShadow: isSarMarker + ? [ + BoxShadow( + color: _getSarMarkerBorderColor(context, isDarkMode).withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -596,46 +614,61 @@ class _MessageBubble extends StatelessWidget { // Header: Sender and time Row( 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) Container( padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, + horizontal: 10, + vertical: 4, ), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(4), + color: _getSarMarkerBorderColor(context, isDarkMode), + borderRadius: BorderRadius.circular(6), ), - child: Text( - 'SAR', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.bold, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + 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( 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) ...[ Row( children: [ @@ -650,15 +683,16 @@ class _MessageBubble extends StatelessWidget { children: [ Text( message.sarMarkerType!.displayName, - style: - Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), ), if (message.sarGpsCoordinates != null) Text( '${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) { - return Theme.of(context).colorScheme.primaryContainer; + Color _getSarMarkerColor(BuildContext context, bool isDarkMode) { + 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; } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 18e3328..a391caf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -134,9 +134,11 @@ class _SettingsScreenState extends State { foundPersonCount: 2, fireCount: 1, stagingCount: 1, + objectCount: 1, ); final channelMessages = SampleDataGenerator.generateChannelMessages( + centerLocation: centerLocation, generalChannelMessages: 8, emergencyChannelMessages: 5, ); diff --git a/lib/utils/sample_data_generator.dart b/lib/utils/sample_data_generator.dart index 540bc67..5697e17 100644 --- a/lib/utils/sample_data_generator.dart +++ b/lib/utils/sample_data_generator.dart @@ -117,6 +117,7 @@ class SampleDataGenerator { int foundPersonCount = 2, int fireCount = 1, int stagingCount = 1, + int objectCount = 1, }) { final messages = []; final now = DateTime.now(); @@ -209,14 +210,53 @@ class SampleDataGenerator { 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; } /// Generate sample channel messages for public channels static List generateChannelMessages({ + LatLng? centerLocation, int generalChannelMessages = 8, int emergencyChannelMessages = 5, }) { + // Use provided location or default to Ljubljana, Slovenia + final center = centerLocation ?? const LatLng(46.0569, 14.5058); final messages = []; final now = DateTime.now(); int messageId = 1000; // Start with high ID to avoid conflicts @@ -238,16 +278,17 @@ class SampleDataGenerator { ]; // Sample messages for Emergency channel (index 1) + // Mix regular messages and SAR markers final emergencyMessages = [ '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', - 'Team member injured - sending location', + 'S:🔥:${(center.latitude + 0.008).toStringAsFixed(4)},${(center.longitude + 0.003).toStringAsFixed(4)} Spreading rapidly!', 'PRIORITY: Need helicopter support', 'Medical team en route to your location', 'Evac helicopter ETA 10 minutes', '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', ]; diff --git a/lib/utils/sar_message_parser.dart b/lib/utils/sar_message_parser.dart index 5c261b7..a44dd99 100644 --- a/lib/utils/sar_message_parser.dart +++ b/lib/utils/sar_message_parser.dart @@ -9,14 +9,19 @@ import '../models/message.dart'; /// S:🔥:40.7128,-74.0060 /// S:🏕️:34.0522,-118.2437 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( - r'^S:(.):(-?\d+\.?\d*),(-?\d+\.?\d*)$', + r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*)', multiLine: false, ); /// Check if a message is a SAR marker message 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 @@ -25,7 +30,9 @@ class SarMessageParser { final trimmed = text.trim(); 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; try { @@ -40,10 +47,24 @@ class SarMessageParser { final markerType = SarMarkerType.fromEmoji(emoji); 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( type: markerType, location: location, emoji: emoji, + notes: notes, ); } catch (e) { return null; @@ -139,15 +160,17 @@ class SarMarkerInfo { final SarMarkerType type; final LatLng location; final String emoji; + final String? notes; SarMarkerInfo({ required this.type, required this.location, required this.emoji, + this.notes, }); @override String toString() { - return 'SarMarkerInfo(type: ${type.displayName}, location: $location)'; + return 'SarMarkerInfo(type: ${type.displayName}, location: $location, notes: $notes)'; } }