mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: MeshCore SAR - Flutter BLE mesh radio companion app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
27
lib/utils/battery_display_helper.dart
Normal file
27
lib/utils/battery_display_helper.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Utility class for battery and signal display helpers
|
||||
/// Used across home_screen.dart and contact_tile.dart
|
||||
class BatteryDisplayHelper {
|
||||
/// Get battery icon based on percentage level
|
||||
static IconData getBatteryIcon(double percentage) {
|
||||
if (percentage > 80) return Icons.battery_full;
|
||||
if (percentage > 50) return Icons.battery_5_bar;
|
||||
if (percentage > 20) return Icons.battery_3_bar;
|
||||
return Icons.battery_1_bar;
|
||||
}
|
||||
|
||||
/// Get battery color based on percentage level
|
||||
static Color getBatteryColor(double percentage) {
|
||||
if (percentage > 50) return Colors.green;
|
||||
if (percentage > 20) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
/// Get signal strength color based on RSSI value
|
||||
static Color getSignalColor(int rssi) {
|
||||
if (rssi > -60) return Colors.green;
|
||||
if (rssi > -70) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
8
lib/utils/debug_print.dart
Normal file
8
lib/utils/debug_print.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Debug print that only outputs in debug builds
|
||||
void debugPrint(Object? message) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(message);
|
||||
}
|
||||
}
|
||||
161
lib/utils/drawing_message_parser.dart
Normal file
161
lib/utils/drawing_message_parser.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'dart:convert';
|
||||
import '../models/map_drawing.dart';
|
||||
|
||||
/// Parser for drawing messages transmitted over mesh network
|
||||
class DrawingMessageParser {
|
||||
/// Drawing message prefix
|
||||
static const String prefix = 'D:';
|
||||
|
||||
/// Check if message is a drawing message
|
||||
static bool isDrawingMessage(String text) {
|
||||
return text.startsWith(prefix);
|
||||
}
|
||||
|
||||
/// Parse drawing message text into MapDrawing object
|
||||
/// senderName and messageId should be extracted from packet metadata
|
||||
/// Returns null if parsing fails
|
||||
static MapDrawing? parseDrawingMessage(
|
||||
String text, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
}) {
|
||||
if (!isDrawingMessage(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove prefix
|
||||
final jsonStr = text.substring(prefix.length);
|
||||
|
||||
// Parse JSON
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
|
||||
// Use ultra-compact network format parser
|
||||
// Sender name and message ID come from packet metadata, not JSON
|
||||
return MapDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create drawing message text from MapDrawing object
|
||||
/// Sender will be determined from packet metadata on receiving end
|
||||
static String createDrawingMessage(MapDrawing drawing) {
|
||||
final json = drawing.toNetworkJson();
|
||||
final jsonStr = jsonEncode(json).toString();
|
||||
return '$prefix$jsonStr';
|
||||
}
|
||||
|
||||
/// Get drawing type display name from drawing message text
|
||||
/// Returns "Line" or "Rectangle", or null if parsing fails
|
||||
static String? getDrawingTypeDisplay(String text) {
|
||||
if (!isDrawingMessage(text)) return null;
|
||||
|
||||
try {
|
||||
final jsonStr = text.substring(prefix.length);
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
final typeNum = json['t'] as int?;
|
||||
|
||||
if (typeNum == null) return null;
|
||||
|
||||
switch (typeNum) {
|
||||
case 0:
|
||||
return 'Line';
|
||||
case 1:
|
||||
return 'Rectangle';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get color name from drawing message text
|
||||
/// Returns color name like "Red", "Blue", etc., or null if parsing fails
|
||||
static String? getColorName(String text) {
|
||||
if (!isDrawingMessage(text)) return null;
|
||||
|
||||
try {
|
||||
final jsonStr = text.substring(prefix.length);
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
final colorIndex = json['c'] as int?;
|
||||
|
||||
if (colorIndex == null) return null;
|
||||
|
||||
// Color mapping from DrawingColor enum
|
||||
const colorNames = [
|
||||
'Red', // 0
|
||||
'Blue', // 1
|
||||
'Green', // 2
|
||||
'Yellow', // 3
|
||||
'Orange', // 4
|
||||
'Purple', // 5
|
||||
'Pink', // 6
|
||||
'Cyan', // 7
|
||||
];
|
||||
|
||||
if (colorIndex >= 0 && colorIndex < colorNames.length) {
|
||||
return colorNames[colorIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get drawing metadata for display in message bubbles
|
||||
/// Returns map with type, color, and pointCount, or null if parsing fails
|
||||
static Map<String, dynamic>? getDrawingMetadata(String text) {
|
||||
if (!isDrawingMessage(text)) return null;
|
||||
|
||||
try {
|
||||
final jsonStr = text.substring(prefix.length);
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
|
||||
final typeNum = json['t'] as int?;
|
||||
final colorIndex = json['c'] as int?;
|
||||
|
||||
if (typeNum == null || colorIndex == null) return null;
|
||||
|
||||
// Get type display name
|
||||
String type;
|
||||
int? pointCount;
|
||||
|
||||
switch (typeNum) {
|
||||
case 0: // Line
|
||||
type = 'Line';
|
||||
final points = json['p'] as List?;
|
||||
pointCount = points != null ? points.length ~/ 2 : null;
|
||||
break;
|
||||
case 1: // Rectangle
|
||||
type = 'Rectangle';
|
||||
pointCount = 4; // Rectangles always have 4 corners
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get color name
|
||||
const colorNames = [
|
||||
'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Purple', 'Pink', 'Cyan',
|
||||
];
|
||||
final color = colorIndex >= 0 && colorIndex < colorNames.length
|
||||
? colorNames[colorIndex]
|
||||
: 'Unknown';
|
||||
|
||||
return {
|
||||
'type': type,
|
||||
'color': color,
|
||||
'pointCount': pointCount,
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/utils/key_comparison.dart
Normal file
18
lib/utils/key_comparison.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Extension on Uint8List to provide comparison functionality for public keys.
|
||||
///
|
||||
/// This extension is used to compare MeshCore public keys (32 bytes) or their
|
||||
/// prefixes (6 bytes) across the application.
|
||||
extension Uint8ListComparison on Uint8List {
|
||||
/// Compares this Uint8List with another for exact equality.
|
||||
///
|
||||
/// Returns true if both lists have the same length and identical bytes.
|
||||
bool matches(Uint8List other) {
|
||||
if (length != other.length) return false;
|
||||
for (int i = 0; i < length; i++) {
|
||||
if (this[i] != other[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
49
lib/utils/message_extensions.dart
Normal file
49
lib/utils/message_extensions.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../models/message.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Extension for Message to provide localized delivery status
|
||||
extension MessageLocalization on Message {
|
||||
/// Get localized delivery status text
|
||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// For channel messages, show echo count instead of delivery status
|
||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||
if (echoCount == 0) {
|
||||
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
||||
} else if (echoCount == 1) {
|
||||
return 'Rebroadcast by 1 node';
|
||||
} else {
|
||||
return 'Rebroadcast by $echoCount nodes';
|
||||
}
|
||||
}
|
||||
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return l10n.sending;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return l10n.sent;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
if (roundTripTimeMs != null) {
|
||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
||||
}
|
||||
return l10n.delivered;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return l10n.failed;
|
||||
case MessageDeliveryStatus.received:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized time ago string
|
||||
String getLocalizedTimeAgo(BuildContext context) {
|
||||
final diff = DateTime.now().difference(sentAt);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (diff.inMinutes < 1) return l10n.justNow;
|
||||
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
}
|
||||
407
lib/utils/sample_data_generator.dart
Normal file
407
lib/utils/sample_data_generator.dart
Normal file
@@ -0,0 +1,407 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_telemetry.dart';
|
||||
import '../models/message.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Generates sample data for testing/demo purposes
|
||||
class SampleDataGenerator {
|
||||
static final Random _random = Random();
|
||||
|
||||
/// Generate sample contacts around a center location
|
||||
static List<Contact> generateContacts({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int teamMemberCount = 5,
|
||||
int channelCount = 2,
|
||||
}) {
|
||||
final contacts = <Contact>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
final teamNames = [
|
||||
'👮${l10n.samplePoliceLead}',
|
||||
'🚁${l10n.sampleDroneOperator}',
|
||||
'🧑🏻🚒${l10n.sampleFirefighterAlpha}',
|
||||
'🧑⚕️${l10n.sampleMedicCharlie}',
|
||||
'📡${l10n.sampleCommandDelta}',
|
||||
'🚒${l10n.sampleFireEngine}',
|
||||
'👨✈️${l10n.sampleAirSupport}',
|
||||
'🧑💼${l10n.sampleBaseCoordinator}',
|
||||
];
|
||||
|
||||
final channelNames = [
|
||||
l10n.general,
|
||||
l10n.channelEmergency,
|
||||
l10n.channelCoordination,
|
||||
l10n.channelUpdates,
|
||||
];
|
||||
|
||||
// Generate team members (chat contacts)
|
||||
for (int i = 0; i < teamMemberCount && i < teamNames.length; i++) {
|
||||
// Generate location within ~1km radius
|
||||
final latOffset = (_random.nextDouble() - 0.5) * 0.02; // ~1km
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * 0.02;
|
||||
final lat = centerLocation.latitude + latOffset;
|
||||
final lon = centerLocation.longitude + lonOffset;
|
||||
|
||||
// Generate random public key
|
||||
final publicKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Random battery 20-100%
|
||||
final battery = 20 + _random.nextInt(81);
|
||||
|
||||
// Random temperature 15-35°C
|
||||
final temp = 15.0 + _random.nextDouble() * 20.0;
|
||||
|
||||
final telemetry = ContactTelemetry(
|
||||
gpsLocation: LatLng(lat, lon),
|
||||
batteryPercentage: battery.toDouble(),
|
||||
batteryMilliVolts: 3000.0 + (battery / 100.0) * 1200.0,
|
||||
temperature: temp,
|
||||
timestamp: now.subtract(Duration(minutes: _random.nextInt(10))),
|
||||
);
|
||||
|
||||
final contact = Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: Uint8List(32),
|
||||
advName: teamNames[i],
|
||||
lastAdvert: now.millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (lat * 1e6).toInt(),
|
||||
advLon: (lon * 1e6).toInt(),
|
||||
lastMod: now.millisecondsSinceEpoch ~/ 1000,
|
||||
telemetry: telemetry,
|
||||
);
|
||||
|
||||
contacts.add(contact);
|
||||
}
|
||||
|
||||
// Generate channels/rooms
|
||||
for (int i = 0; i < channelCount && i < channelNames.length; i++) {
|
||||
// Generate random public key
|
||||
final publicKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Channel index stored in outPath[0]
|
||||
final outPath = Uint8List(32);
|
||||
outPath[0] = i; // Channel index
|
||||
|
||||
final channel = Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.room,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: outPath,
|
||||
advName: channelNames[i],
|
||||
lastAdvert: now.millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0, // Channels don't have location
|
||||
advLon: 0,
|
||||
lastMod: now.millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
contacts.add(channel);
|
||||
}
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
/// Generate sample SAR markers around a center location
|
||||
static List<Message> generateSarMarkerMessages({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int foundPersonCount = 2,
|
||||
int fireCount = 1,
|
||||
int stagingCount = 1,
|
||||
int objectCount = 1,
|
||||
}) {
|
||||
final messages = <Message>[];
|
||||
final now = DateTime.now();
|
||||
int messageId = 1;
|
||||
|
||||
// Generate found person markers
|
||||
for (int i = 0; i < foundPersonCount; 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: 10 + i * 5));
|
||||
messages.add(Message(
|
||||
id: 'sample_fp_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🧑:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🧑',
|
||||
senderName: l10n.sampleTeamMember,
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate fire markers
|
||||
for (int i = 0; i < fireCount; 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: 20 + i * 5));
|
||||
messages.add(Message(
|
||||
id: 'sample_fire_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🔥:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🔥',
|
||||
senderName: l10n.sampleScout,
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate staging area markers
|
||||
for (int i = 0; i < stagingCount; 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: 30 + i * 5));
|
||||
messages.add(Message(
|
||||
id: 'sample_staging_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:🏕️:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🏕️',
|
||||
senderName: l10n.sampleBase,
|
||||
));
|
||||
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 = [
|
||||
l10n.sampleObjectBackpack,
|
||||
l10n.sampleObjectVehicle,
|
||||
l10n.sampleObjectCamping,
|
||||
l10n.sampleObjectTrailMarker,
|
||||
];
|
||||
|
||||
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(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '📦',
|
||||
senderName: l10n.sampleSearcher,
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// Generate sample map drawings
|
||||
static List<dynamic> generateDrawings({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
final drawings = <dynamic>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
// Generate a line drawing (e.g., search path)
|
||||
final linePoints = <LatLng>[
|
||||
LatLng(centerLocation.latitude + 0.002, centerLocation.longitude - 0.003),
|
||||
LatLng(centerLocation.latitude + 0.004, centerLocation.longitude - 0.002),
|
||||
LatLng(centerLocation.latitude + 0.005, centerLocation.longitude + 0.001),
|
||||
LatLng(centerLocation.latitude + 0.003, centerLocation.longitude + 0.003),
|
||||
];
|
||||
|
||||
drawings.add({
|
||||
'type': 'line',
|
||||
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
||||
'color': Colors.blue.toARGB32(),
|
||||
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
|
||||
'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
|
||||
'sender': l10n.sampleTeamMember,
|
||||
});
|
||||
|
||||
// Generate a rectangle drawing (e.g., search area)
|
||||
drawings.add({
|
||||
'type': 'rectangle',
|
||||
'id': 'sample_rect_${now.millisecondsSinceEpoch + 1}',
|
||||
'color': Colors.red.toARGB32(),
|
||||
'createdAt': now.subtract(const Duration(minutes: 10)).toIso8601String(),
|
||||
'topLeft': {
|
||||
'lat': centerLocation.latitude - 0.003,
|
||||
'lon': centerLocation.longitude - 0.004,
|
||||
},
|
||||
'bottomRight': {
|
||||
'lat': centerLocation.latitude - 0.001,
|
||||
'lon': centerLocation.longitude - 0.001,
|
||||
},
|
||||
'sender': l10n.sampleScout,
|
||||
});
|
||||
|
||||
return drawings;
|
||||
}
|
||||
|
||||
/// Generate sample channel messages for public channels
|
||||
static List<Message> generateChannelMessages({
|
||||
LatLng? centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
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 = <Message>[];
|
||||
final now = DateTime.now();
|
||||
int messageId = 1000; // Start with high ID to avoid conflicts
|
||||
|
||||
// Sample messages for General channel (index 0)
|
||||
final generalMessages = [
|
||||
l10n.sampleMsgAllTeamsCheckIn,
|
||||
l10n.sampleMsgWeatherUpdate,
|
||||
l10n.sampleMsgBaseCamp,
|
||||
l10n.sampleMsgTeamAlpha,
|
||||
l10n.sampleMsgRadioCheck,
|
||||
l10n.sampleMsgWaterSupply,
|
||||
l10n.sampleMsgTeamBravo,
|
||||
l10n.sampleMsgEtaRallyPoint,
|
||||
l10n.sampleMsgSupplyDrop,
|
||||
l10n.sampleMsgDroneSurvey,
|
||||
l10n.sampleMsgTeamCharlie,
|
||||
l10n.sampleMsgRadioDiscipline,
|
||||
];
|
||||
|
||||
// Sample messages for Emergency channel (index 1)
|
||||
// Mix regular messages and SAR markers
|
||||
final emergencyMessages = [
|
||||
l10n.sampleMsgUrgentMedical,
|
||||
'S:🧑:${center.latitude.toStringAsFixed(5)},${(center.longitude + 0.005).toStringAsFixed(5)}${l10n.sampleMsgAdultMale}',
|
||||
l10n.sampleMsgFireSpotted,
|
||||
'S:🔥:${(center.latitude + 0.008).toStringAsFixed(5)},${(center.longitude + 0.003).toStringAsFixed(5)}${l10n.sampleMsgSpreadingRapidly}',
|
||||
l10n.sampleMsgPriorityHelicopter,
|
||||
l10n.sampleMsgMedicalTeamEnRoute,
|
||||
l10n.sampleMsgEvacHelicopter,
|
||||
l10n.sampleMsgEmergencyResolved,
|
||||
'S:🏕️:${(center.latitude - 0.002).toStringAsFixed(5)},${(center.longitude - 0.004).toStringAsFixed(5)}${l10n.sampleMsgEmergencyStagingArea}',
|
||||
l10n.sampleMsgEmergencyServices,
|
||||
];
|
||||
|
||||
final teamNames = [
|
||||
l10n.sampleAlphaTeamLead,
|
||||
l10n.sampleBravoScout,
|
||||
l10n.sampleCharlieMedic,
|
||||
l10n.sampleDeltaNavigator,
|
||||
l10n.sampleEchoSupport,
|
||||
l10n.sampleBaseCommand,
|
||||
l10n.sampleFieldCoordinator,
|
||||
l10n.sampleMedicalTeam,
|
||||
];
|
||||
|
||||
// Generate General channel messages
|
||||
for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) {
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Messages spread over the last 2 hours
|
||||
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||
|
||||
messages.add(Message(
|
||||
id: 'sample_general_$messageId',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0, // General channel
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: generalMessages[i],
|
||||
receivedAt: timestamp,
|
||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate Emergency channel messages
|
||||
for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) {
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
|
||||
// Emergency messages more recent (last hour)
|
||||
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||
|
||||
messages.add(Message(
|
||||
id: 'sample_emergency_$messageId',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 1, // Emergency channel
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: emergencyMessages[i],
|
||||
receivedAt: timestamp,
|
||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||
));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
24
lib/utils/sar_marker_extensions.dart
Normal file
24
lib/utils/sar_marker_extensions.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Extension for SarMarkerType to provide localized display names
|
||||
extension SarMarkerTypeLocalization on SarMarkerType {
|
||||
/// Get localized display name for this SAR marker type
|
||||
String getLocalizedName(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
switch (this) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return l10n.foundPerson;
|
||||
case SarMarkerType.fire:
|
||||
return l10n.fire;
|
||||
case SarMarkerType.stagingArea:
|
||||
return l10n.stagingArea;
|
||||
case SarMarkerType.object:
|
||||
return 'Object'; // Not commonly used, keeping English for now
|
||||
case SarMarkerType.unknown:
|
||||
return 'Unknown'; // Not commonly used, keeping English for now
|
||||
}
|
||||
}
|
||||
}
|
||||
227
lib/utils/sar_message_parser.dart
Normal file
227
lib/utils/sar_message_parser.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
/// Parser for SAR (Search & Rescue) special messages
|
||||
/// Old format: `S:<emoji>:<latitude>,<longitude>:<optional_message>`
|
||||
/// New format: `S:<emoji>:<colorIndex>:<latitude>,<longitude>:<optional_message>`
|
||||
/// Examples:
|
||||
/// S:🧑:37.7749,-122.4194 (old format)
|
||||
/// S:🧑:2:37.7749,-122.4194 (new format with green color)
|
||||
/// S:🔥:0:40.7128,-74.0060:Large wildfire spreading (new format with red color)
|
||||
class SarMessageParser {
|
||||
// Regex for new format with color index: S:emoji:colorIndex:lat,lon:notes
|
||||
// Captures: emoji, colorIndex (single digit), latitude, longitude, optional message
|
||||
static final RegExp _sarPatternNew = RegExp(
|
||||
r'^S:([^:]+):(\d):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
|
||||
multiLine: false,
|
||||
);
|
||||
|
||||
// Regex for old format (backward compatibility): S:emoji:lat,lon:notes
|
||||
// Captures: emoji, latitude, longitude, optional message
|
||||
static final RegExp _sarPatternOld = RegExp(
|
||||
r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
|
||||
multiLine: false,
|
||||
);
|
||||
|
||||
/// Check if a message is a SAR marker message
|
||||
static bool isSarMessage(String text) {
|
||||
// Extract just the first line for matching
|
||||
final firstLine = text.trim().split('\n').first;
|
||||
return firstLine.startsWith('S:') &&
|
||||
(_sarPatternNew.hasMatch(firstLine) ||
|
||||
_sarPatternOld.hasMatch(firstLine));
|
||||
}
|
||||
|
||||
/// Parse a SAR message and extract marker information
|
||||
/// Returns null if the message is not a valid SAR message
|
||||
/// Supports both old format (S:emoji:lat,lon:notes) and new format (S:emoji:colorIndex:lat,lon:notes)
|
||||
static SarMarkerInfo? parse(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (!trimmed.startsWith('S:')) return null;
|
||||
|
||||
// Extract first line (actual SAR marker)
|
||||
final firstLine = trimmed.split('\n').first;
|
||||
|
||||
// Try new format first (with color index)
|
||||
var match = _sarPatternNew.firstMatch(firstLine);
|
||||
bool isNewFormat = match != null;
|
||||
|
||||
// If new format didn't match, try old format
|
||||
if (match == null) {
|
||||
match = _sarPatternOld.firstMatch(firstLine);
|
||||
if (match == null) return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String emoji;
|
||||
double latitude;
|
||||
double longitude;
|
||||
String? inlineMessage;
|
||||
int? colorIndex;
|
||||
|
||||
if (isNewFormat) {
|
||||
// New format: S:emoji:colorIndex:lat,lon:notes
|
||||
emoji = match.group(1)!;
|
||||
colorIndex = int.parse(match.group(2)!);
|
||||
latitude = double.parse(match.group(3)!);
|
||||
longitude = double.parse(match.group(4)!);
|
||||
inlineMessage = match.group(5)?.trim();
|
||||
} else {
|
||||
// Old format: S:emoji:lat,lon:notes
|
||||
emoji = match.group(1)!;
|
||||
colorIndex = null; // No color index in old format
|
||||
latitude = double.parse(match.group(2)!);
|
||||
longitude = double.parse(match.group(3)!);
|
||||
inlineMessage = match.group(4)?.trim();
|
||||
}
|
||||
|
||||
// Validate coordinates
|
||||
if (latitude < -90 || latitude > 90) return null;
|
||||
if (longitude < -180 || longitude > 180) return null;
|
||||
|
||||
// Validate color index if present
|
||||
if (colorIndex != null && (colorIndex < 0 || colorIndex > 7)) {
|
||||
colorIndex = null; // Invalid index, ignore it
|
||||
}
|
||||
|
||||
final markerType = SarMarkerType.fromEmoji(emoji);
|
||||
final location = LatLng(latitude, longitude);
|
||||
|
||||
// Combine inline message with multi-line notes
|
||||
String? notes;
|
||||
if (inlineMessage != null && inlineMessage.isNotEmpty) {
|
||||
notes = inlineMessage;
|
||||
}
|
||||
|
||||
// Check for multi-line notes (lines after the first line)
|
||||
final additionalNotes = extractNotes(text);
|
||||
if (additionalNotes != null) {
|
||||
notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes;
|
||||
}
|
||||
|
||||
return SarMarkerInfo(
|
||||
type: markerType,
|
||||
location: location,
|
||||
emoji: emoji,
|
||||
notes: notes,
|
||||
colorIndex: colorIndex,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhance a Message with SAR marker information
|
||||
static Message enhanceMessage(Message message) {
|
||||
final sarInfo = parse(message.text);
|
||||
if (sarInfo == null) return message;
|
||||
|
||||
return message.copyWith(
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: sarInfo.location,
|
||||
sarNotes: sarInfo.notes, // Extract and store notes
|
||||
sarCustomEmoji: sarInfo.emoji, // Always store emoji for type inference
|
||||
sarColorIndex: sarInfo.colorIndex, // Store color index
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a SAR marker message text (new format with color index)
|
||||
static String createSarMessage({
|
||||
required SarMarkerType type,
|
||||
required LatLng location,
|
||||
String? notes,
|
||||
int? colorIndex,
|
||||
}) {
|
||||
// New format: S:emoji:colorIndex:lat,lon:notes
|
||||
final colorIdx = colorIndex ?? 0; // Default to red if not specified
|
||||
final text =
|
||||
'S:${type.emoji}:$colorIdx:${location.latitude.toString()},${location.longitude.toString()}';
|
||||
if (notes != null && notes.isNotEmpty) {
|
||||
// Use colon-separated format for inline message
|
||||
return '$text:$notes';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Extract additional notes from SAR message (text after the marker)
|
||||
static String? extractNotes(String text) {
|
||||
final trimmed = text.trim();
|
||||
final lines = trimmed.split('\n');
|
||||
if (lines.length <= 1) return null;
|
||||
|
||||
// Everything after the first line is considered notes
|
||||
return lines.sublist(1).join('\n').trim();
|
||||
}
|
||||
|
||||
/// Validate SAR message format
|
||||
static bool isValidFormat(String text) {
|
||||
return isSarMessage(text) && parse(text) != null;
|
||||
}
|
||||
|
||||
/// Get a user-friendly error message for invalid SAR format
|
||||
static String? getFormatError(String text) {
|
||||
if (!text.trim().startsWith('S:')) {
|
||||
return 'SAR message must start with "S:"';
|
||||
}
|
||||
|
||||
final parts = text.trim().split(':');
|
||||
if (parts.length < 3) {
|
||||
return 'Invalid format. Use: S:<emoji>:<latitude>,<longitude>';
|
||||
}
|
||||
|
||||
final emoji = parts[1];
|
||||
if (emoji.isEmpty) {
|
||||
return 'Missing emoji marker (🧑, 🔥, or 🏕️)';
|
||||
}
|
||||
|
||||
final coords = parts[2];
|
||||
if (!coords.contains(',')) {
|
||||
return 'Coordinates must be separated by comma';
|
||||
}
|
||||
|
||||
final coordParts = coords.split(',');
|
||||
if (coordParts.length != 2) {
|
||||
return 'Invalid coordinates format';
|
||||
}
|
||||
|
||||
try {
|
||||
final lat = double.parse(coordParts[0]);
|
||||
final lon = double.parse(coordParts[1]);
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
return 'Latitude must be between -90 and 90';
|
||||
}
|
||||
if (lon < -180 || lon > 180) {
|
||||
return 'Longitude must be between -180 and 180';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Invalid coordinate values';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed SAR marker information
|
||||
class SarMarkerInfo {
|
||||
final SarMarkerType type;
|
||||
final LatLng location;
|
||||
final String emoji;
|
||||
final String? notes;
|
||||
final int?
|
||||
colorIndex; // Color index from standard palette (0-7), null for backward compatibility
|
||||
|
||||
SarMarkerInfo({
|
||||
required this.type,
|
||||
required this.location,
|
||||
required this.emoji,
|
||||
this.notes,
|
||||
this.colorIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SarMarkerInfo(type: ${type.displayName}, location: $location, colorIndex: $colorIndex, notes: $notes)';
|
||||
}
|
||||
}
|
||||
87
lib/utils/slovenian_crs.dart
Normal file
87
lib/utils/slovenian_crs.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:math' show Point;
|
||||
import 'dart:ui' show Rect;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:proj4dart/proj4dart.dart' as proj4;
|
||||
|
||||
/// EPSG:3794 - Slovenia 1996 / Slovene National Grid
|
||||
/// Transverse Mercator projection for Slovenia
|
||||
///
|
||||
/// Official definition from https://epsg.io/3794:
|
||||
/// +proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000
|
||||
/// +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs
|
||||
///
|
||||
/// This CRS is used by Slovenian government WMS services (prostor.zgs.gov.si)
|
||||
|
||||
/// Register and get EPSG:3794 projection
|
||||
proj4.Projection getEpsg3794Projection() {
|
||||
const epsg3794Def =
|
||||
'+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 '
|
||||
'+x_0=500000 +y_0=-5000000 +ellps=GRS80 '
|
||||
'+towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs';
|
||||
|
||||
// Register projection if not already registered
|
||||
try {
|
||||
return proj4.Projection.get('EPSG:3794') ??
|
||||
proj4.Projection.add('EPSG:3794', epsg3794Def);
|
||||
} catch (e) {
|
||||
// If already registered, get it
|
||||
return proj4.Projection.get('EPSG:3794')!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Proj4Crs for EPSG:3794
|
||||
///
|
||||
/// Configuration matches the GeoWebCache tile grid used by prostor.zgs.gov.si
|
||||
///
|
||||
/// Official tile grid from WMTS GetCapabilities:
|
||||
/// - TopLeftCorner: 373217.6542445397, 246158.298050262
|
||||
/// - Bounds: X: 373217.65 to 695777.65, Y: 31118.30 to 246158.30
|
||||
/// - Tile size: 256x256 pixels
|
||||
/// - Scale denominators converted to resolutions using: resolution = scaleDenom * 0.00028
|
||||
Crs getSlovenianCrs() {
|
||||
final projection = getEpsg3794Projection();
|
||||
|
||||
// Resolutions calculated from GeoWebCache scale denominators
|
||||
// Formula: resolution (m/px) = scaleDenominator * 0.00028 (OGC standard)
|
||||
final resolutions = <double>[
|
||||
420.0, // Zoom 0 - ScaleDenom: 1500000
|
||||
280.0, // Zoom 1 - ScaleDenom: 1000000
|
||||
210.0, // Zoom 2 - ScaleDenom: 750000
|
||||
140.0, // Zoom 3 - ScaleDenom: 500000
|
||||
70.0, // Zoom 4 - ScaleDenom: 250000
|
||||
28.0, // Zoom 5 - ScaleDenom: 100000
|
||||
14.0, // Zoom 6 - ScaleDenom: 50000
|
||||
7.0, // Zoom 7 - ScaleDenom: 25000
|
||||
4.2, // Zoom 8 - ScaleDenom: 15000
|
||||
2.8, // Zoom 9 - ScaleDenom: 10000
|
||||
1.4, // Zoom 10 - ScaleDenom: 5000
|
||||
0.56, // Zoom 11 - ScaleDenom: 2000
|
||||
0.28, // Zoom 12 - ScaleDenom: 1000
|
||||
0.14, // Zoom 13 - ScaleDenom: 500
|
||||
0.07, // Zoom 14 - ScaleDenom: 250
|
||||
0.028, // Zoom 15 - ScaleDenom: 100
|
||||
];
|
||||
|
||||
// Bounds from WMS capabilities (actual data extent in Slovenia)
|
||||
final bounds = Rect.fromLTRB(
|
||||
373217.65, // min X (west)
|
||||
31118.30, // min Y (south) - top in Rect coordinates
|
||||
695777.65, // max X (east)
|
||||
246158.30, // max Y (north) - bottom in Rect coordinates
|
||||
);
|
||||
|
||||
// Origin from WMTS TileMatrixSet TopLeftCorner
|
||||
// This is the top-left corner of the tile pyramid (min X, max Y)
|
||||
final origin = Point<double>(373217.6542445397, 246158.298050262);
|
||||
|
||||
return Proj4Crs.fromFactory(
|
||||
code: 'EPSG:3794',
|
||||
proj4Projection: projection,
|
||||
resolutions: resolutions,
|
||||
bounds: bounds,
|
||||
origins: [origin],
|
||||
);
|
||||
}
|
||||
|
||||
/// Singleton instance of Slovenian CRS for reuse
|
||||
final Crs slovenianCrs = getSlovenianCrs();
|
||||
25
lib/utils/time_ago_extensions.dart
Normal file
25
lib/utils/time_ago_extensions.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Extension to provide localized "time ago" formatting
|
||||
extension TimeAgoExtension on Duration {
|
||||
/// Get localized time ago string
|
||||
String toLocalizedTimeAgo(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (inMinutes < 1) return l10n.justNow;
|
||||
if (inMinutes < 60) return l10n.minutesAgo(inMinutes);
|
||||
if (inHours < 24) return l10n.hoursAgo(inHours);
|
||||
return l10n.daysAgo(inDays);
|
||||
}
|
||||
|
||||
/// Get localized time ago string with seconds support
|
||||
String toLocalizedTimeAgoWithSeconds(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (inSeconds < 60) return l10n.secondsAgo(inSeconds);
|
||||
if (inMinutes < 60) return l10n.minutesAgo(inMinutes);
|
||||
if (inHours < 24) return l10n.hoursAgo(inHours);
|
||||
return l10n.daysAgo(inDays);
|
||||
}
|
||||
}
|
||||
37
lib/utils/toast_logger.dart
Normal file
37
lib/utils/toast_logger.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
|
||||
/// Toast logger utility - replaces SnackBar with system messages in the channels tab
|
||||
class ToastLogger {
|
||||
/// Log an info message
|
||||
static void info(BuildContext context, String message) {
|
||||
_log(context, message, 'info');
|
||||
}
|
||||
|
||||
/// Log a success message
|
||||
static void success(BuildContext context, String message) {
|
||||
_log(context, message, 'success');
|
||||
}
|
||||
|
||||
/// Log a warning message
|
||||
static void warning(BuildContext context, String message) {
|
||||
_log(context, message, 'warning');
|
||||
}
|
||||
|
||||
/// Log an error message
|
||||
static void error(BuildContext context, String message) {
|
||||
_log(context, message, 'error');
|
||||
}
|
||||
|
||||
/// Internal method to log a system message
|
||||
static void _log(BuildContext context, String message, String level) {
|
||||
try {
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
messagesProvider.logSystemMessage(text: message, level: level);
|
||||
} catch (e) {
|
||||
// Fallback to print if provider is not available
|
||||
debugPrint('[$level] $message');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user