feat: MeshCore SAR - Flutter BLE mesh radio companion app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janez T
2026-02-28 10:11:33 +01:00
commit baf49d27d8
313 changed files with 101770 additions and 0 deletions

View 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;
}
}

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

View 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;
}
}
}

View 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;
}
}

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

View 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;
}
}

View 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
}
}
}

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

View 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();

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

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