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,40 @@
import 'package:latlong2/latlong.dart';
/// Single advertisement location point in a contact's movement history
class AdvertLocation {
final LatLng location;
final DateTime timestamp;
AdvertLocation({
required this.location,
required this.timestamp,
});
/// Get friendly time ago display
String get timeAgo {
final diff = DateTime.now().difference(timestamp);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
@override
String toString() {
return 'AdvertLocation(lat: ${location.latitude.toStringAsFixed(6)}, '
'lon: ${location.longitude.toStringAsFixed(6)}, '
'time: ${timestamp.toIso8601String()})';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AdvertLocation &&
other.location.latitude == location.latitude &&
other.location.longitude == location.longitude &&
other.timestamp == timestamp;
}
@override
int get hashCode => Object.hash(location.latitude, location.longitude, timestamp);
}

View File

@@ -0,0 +1,121 @@
import 'dart:typed_data';
import '../services/meshcore_opcode_names.dart';
/// Decoded LOG_RX_DATA packet structure
class LogRxDataInfo {
final int? airtimeMs;
final Uint8List? senderPublicKey;
final int? ackCode;
final List<String> embeddedStrings;
final double entropy;
final bool isLikelyEncrypted;
final double? snrDb; // Signal-to-Noise Ratio in dB
final int? rssiDbm; // Received Signal Strength Indicator in dBm
LogRxDataInfo({
this.airtimeMs,
this.senderPublicKey,
this.ackCode,
this.embeddedStrings = const [],
required this.entropy,
required this.isLikelyEncrypted,
this.snrDb,
this.rssiDbm,
});
/// Get sender public key as hex string (short)
String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 6) return null;
return senderPublicKey!
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
}
String get summary {
final parts = <String>[];
if (rssiDbm != null) parts.add('RSSI:${rssiDbm}dBm');
final snr = snrDb;
if (snr != null) parts.add('SNR:${snr.toStringAsFixed(1)}dB');
if (airtimeMs != null) parts.add('airtime:${airtimeMs}ms');
if (ackCode != null) parts.add('ACK:$ackCode');
if (senderKeyShort != null) parts.add('from:$senderKeyShort');
if (embeddedStrings.isNotEmpty) parts.add('strings:${embeddedStrings.length}');
if (isLikelyEncrypted) parts.add('encrypted');
return parts.join(', ');
}
}
/// Represents a logged BLE packet with timestamp and metadata
class BlePacketLog {
final DateTime timestamp;
final Uint8List rawData;
final PacketDirection direction;
final int? responseCode;
final String? description;
final LogRxDataInfo? logRxDataInfo; // Decoded LOG_RX_DATA information
BlePacketLog({
required this.timestamp,
required this.rawData,
required this.direction,
this.responseCode,
this.description,
this.logRxDataInfo,
});
/// Convert raw data to hex string for display
String get hexData {
return rawData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
/// Get opcode name for this packet
String get opcodeName {
if (responseCode == null) return 'N/A';
return MeshCoreOpcodeNames.getOpcodeName(
responseCode!,
isTx: direction == PacketDirection.tx,
);
}
/// Get full opcode description (name + hex code)
String get opcodeDescription {
if (responseCode == null) return 'N/A';
return MeshCoreOpcodeNames.getOpcodeDescription(
responseCode!,
isTx: direction == PacketDirection.tx,
);
}
/// Get short summary of the packet
String get summary {
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
final code = responseCode != null ? '0x${responseCode!.toRadixString(16).padLeft(2, '0')}' : 'N/A';
final name = responseCode != null ? opcodeName : '';
return '[$dir] $name Code: $code, Size: ${rawData.length} bytes';
}
/// Convert to CSV format for export
String toCsvRow() {
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
final code = responseCode?.toString() ?? '';
final name = responseCode != null ? opcodeName : '';
final hex = hexData;
final desc = description ?? '';
return '${timestamp.toIso8601String()},$dir,${rawData.length},$name,$code,"$hex","$desc"';
}
/// Convert to human-readable log format
String toLogString() {
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
final code = responseCode != null ? ' [$opcodeDescription]' : '';
final desc = description != null ? ' - $description' : '';
final logRxInfo = logRxDataInfo != null ? ' [${logRxDataInfo!.summary}]' : '';
return '${timestamp.toIso8601String()} [$dir]$code ${rawData.length} bytes: $hexData$desc$logRxInfo';
}
}
enum PacketDirection {
rx, // Received from device
tx, // Sent to device
}

173
lib/models/channel.dart Normal file
View File

@@ -0,0 +1,173 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
/// Channel model - represents a communication channel
///
/// Supports two types of channels:
/// 1. Hash-based channels: Names starting with '#' (e.g., '#team', '#sar-ops')
/// - Secrets are auto-generated using SHA256(name)
/// - Same name produces same secret on all devices
/// 2. Normal channels: Any name with explicit secret
/// - User provides explicit 16-byte secret
/// - Only known to those who share the secret
class Channel {
final int index; // 0-255
final String name;
final Uint8List secret; // 16 bytes
final int? flags;
Channel({
required this.index,
required this.name,
required this.secret,
this.flags,
}) {
if (secret.length != 16) {
throw ArgumentError('Channel secret must be exactly 16 bytes');
}
if (index < 0 || index > 255) {
throw ArgumentError('Channel index must be 0-255');
}
}
/// Create a channel with auto-generated secret for #channels
///
/// For #channels (name starting with '#'):
/// - Secret is auto-generated using SHA256(name)[0:16]
/// - Deterministic: same name = same secret across all devices
///
/// For normal channels:
/// - Must provide explicit 16-byte secret
factory Channel.create({
required int index,
required String name,
Uint8List? explicitSecret,
int? flags,
}) {
if (name.startsWith('#')) {
// Hash-based channel: auto-generate secret from name
if (explicitSecret != null) {
throw ArgumentError(
'Cannot provide explicit secret for #channel. Secret is auto-generated.',
);
}
final secret = _generateHashChannelSecret(name);
return Channel(index: index, name: name, secret: secret, flags: flags);
} else {
// Normal channel: require explicit secret
if (explicitSecret == null || explicitSecret.length != 16) {
throw ArgumentError(
'Normal channels require a 16-byte secret',
);
}
return Channel(
index: index,
name: name,
secret: explicitSecret,
flags: flags,
);
}
}
/// Generate secret for #channel using SHA256
/// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16]
static Uint8List _generateHashChannelSecret(String channelName) {
final bytes = utf8.encode(channelName);
final digest = sha256.convert(bytes);
return Uint8List.fromList(digest.bytes.sublist(0, 16));
}
/// Create the default public channel (channel 0)
/// Uses the well-known pre-shared key from MeshCore
factory Channel.publicChannel() {
return Channel(
index: 0,
name: 'Public Channel',
secret: Uint8List.fromList([
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
]),
flags: null,
);
}
/// Check if this is a hash-based channel (name starts with '#')
bool get isHashChannel => name.startsWith('#');
/// Display name for the channel
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
String get displayName {
if (index == 0) {
return name.isEmpty ? 'Public' : name;
}
return name.isEmpty ? 'Channel $index' : name;
}
/// Check if channel is the public channel (index 0)
bool get isPublicChannel => index == 0;
/// Check if channel has a custom name
bool get hasCustomName => name.isNotEmpty;
/// Create from JSON
factory Channel.fromJson(Map<String, dynamic> json) {
return Channel(
index: json['index'] as int,
name: json['name'] as String? ?? '',
secret: base64.decode(json['secret'] as String),
flags: json['flags'] as int?,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'index': index,
'name': name,
'secret': base64.encode(secret),
'flags': flags,
};
}
/// Create a copy with modified fields
Channel copyWith({
int? index,
String? name,
Uint8List? secret,
int? flags,
}) {
return Channel(
index: index ?? this.index,
name: name ?? this.name,
secret: secret ?? this.secret,
flags: flags ?? this.flags,
);
}
@override
String toString() {
return 'Channel(index: $index, name: $name, isHashChannel: $isHashChannel, flags: $flags)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Channel &&
other.index == index &&
other.name == name &&
_secretsEqual(other.secret, secret) &&
other.flags == flags;
}
@override
int get hashCode => Object.hash(index, name, secret, flags);
bool _secretsEqual(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}

364
lib/models/contact.dart Normal file
View File

@@ -0,0 +1,364 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
import 'package:flutter/material.dart';
import 'contact_telemetry.dart';
import 'advert_location.dart';
import '../l10n/app_localizations.dart';
/// MeshCore contact types
enum ContactType {
none(0),
chat(1),
repeater(2),
room(3),
channel(99); // Virtual type for public channel (not from protocol)
const ContactType(this.value);
final int value;
static ContactType fromValue(int value) {
return ContactType.values.firstWhere(
(e) => e.value == value,
orElse: () => ContactType.none,
);
}
String get displayName {
switch (this) {
case ContactType.chat:
return 'Chat';
case ContactType.repeater:
return 'Repeater';
case ContactType.room:
return 'Room';
case ContactType.channel:
return 'Channel';
default:
return 'Unknown';
}
}
}
/// MeshCore contact model
class Contact {
final Uint8List publicKey;
final ContactType type;
final int flags;
final int outPathLen;
final Uint8List outPath;
final String advName;
final int lastAdvert; // Unix timestamp
final int advLat; // Latitude as int32
final int advLon; // Longitude as int32
final int lastMod; // Unix timestamp
// Telemetry data (updated separately)
ContactTelemetry? telemetry;
// Advertisement location history (most recent first)
final List<AdvertLocation> advertHistory;
// UI state tracking
final bool isNew; // Whether contact is newly added and not yet viewed
Contact({
required this.publicKey,
required this.type,
required this.flags,
required this.outPathLen,
required this.outPath,
required this.advName,
required this.lastAdvert,
required this.advLat,
required this.advLon,
required this.lastMod,
this.telemetry,
List<AdvertLocation>? advertHistory,
this.isNew = false,
}) : advertHistory = advertHistory ?? [];
/// Get public key as hex string (first 8 bytes)
String get publicKeyShort {
if (publicKey.length < 8) return '';
return publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Get full public key as hex string
String get publicKeyHex {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Get public key prefix (first 6 bytes) for room login matching
Uint8List get publicKeyPrefix {
if (publicKey.length < 6) return publicKey;
return publicKey.sublist(0, 6);
}
/// Convert advLat/advLon to LatLng
LatLng? get advertLocation {
if (advLat == 0 && advLon == 0) return null;
// Convert from int32 to double (degrees)
final lat = advLat / 1e6;
final lon = advLon / 1e6;
return LatLng(lat, lon);
}
/// Get display location (prefer telemetry over advert)
LatLng? get displayLocation {
if (telemetry?.gpsLocation != null && telemetry!.isRecent) {
return telemetry!.gpsLocation;
}
return advertLocation;
}
/// Get display battery (from telemetry or null)
double? get displayBattery {
return telemetry?.batteryPercentage;
}
/// Check if contact is a chat type (team member)
bool get isChat => type == ContactType.chat;
/// Check if contact is a repeater
bool get isRepeater => type == ContactType.repeater;
/// Check if contact is a room (persistent storage)
bool get isRoom => type == ContactType.room;
/// Check if contact is a channel (ephemeral broadcast)
bool get isChannel => type == ContactType.channel;
/// Get last seen time
DateTime get lastSeenTime {
return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000);
}
/// Get last modified time
DateTime get lastModifiedTime {
return DateTime.fromMillisecondsSinceEpoch(lastMod * 1000);
}
/// Check if contact was seen recently (within last 10 minutes)
bool get isRecentlySeen {
return DateTime.now().difference(lastSeenTime).inMinutes < 10;
}
/// Get friendly time since last seen
String get timeSinceLastSeen {
final diff = DateTime.now().difference(lastSeenTime);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
/// Get time when location was last updated
DateTime? get locationUpdateTime {
// Prefer telemetry timestamp if available
if (telemetry?.gpsLocation != null) {
return telemetry!.timestamp;
}
// Fall back to lastAdvert time if using advertised location
if (advertLocation != null) {
return lastSeenTime;
}
return null;
}
/// Get friendly time since location was last updated
String get timeSinceLocationUpdate {
final updateTime = locationUpdateTime;
if (updateTime == null) return 'Unknown';
final diff = DateTime.now().difference(updateTime);
if (diff.inMinutes < 1) return 'Now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays}d';
}
/// Extract role emoji from name (e.g., "🧑🏻🚒Janez" → "🧑🏻‍🚒")
/// Returns null if no emoji at start of name
String? get roleEmoji {
if (advName.isEmpty) return null;
// Get the first character/grapheme cluster (which could be a complex emoji)
final firstChar = advName.characters.first;
// Check if it's an emoji (basic check - emojis are typically in certain Unicode ranges)
final firstCodeUnit = firstChar.runes.first;
// Emoji ranges (simplified check):
// 0x1F300-0x1F9FF: Misc Symbols and Pictographs, Emoticons, Transport, etc.
// 0x2600-0x26FF: Misc symbols
// 0x2700-0x27BF: Dingbats
// 0xFE00-0xFE0F: Variation Selectors
// 0x1F900-0x1F9FF: Supplemental Symbols and Pictographs
if ((firstCodeUnit >= 0x1F300 && firstCodeUnit <= 0x1F9FF) ||
(firstCodeUnit >= 0x2600 && firstCodeUnit <= 0x27BF) ||
(firstCodeUnit >= 0x1F600 && firstCodeUnit <= 0x1F64F)) {
return firstChar;
}
return null;
}
/// Get display name without role emoji (e.g., "🧑🏻🚒Janez" → "Janez")
/// If no emoji, returns full advName
String get displayName {
final emoji = roleEmoji;
if (emoji == null) return advName;
// Remove the emoji from the beginning
return advName.substring(emoji.length).trim();
}
/// Check if this contact is the Public Channel (all-zeros public key)
bool get isPublicChannel =>
publicKeyHex == '0000000000000000000000000000000000000000000000000000000000000000';
/// Get localized display name (for Public Channel and other special contacts)
String getLocalizedDisplayName(BuildContext context) {
// Check if this is the Public Channel (all-zeros public key)
if (isPublicChannel) {
return AppLocalizations.of(context)!.publicChannel;
}
// For all other contacts, use the regular display name
return displayName;
}
/// Check if contact has a learned routing path
/// When true, messages will use direct routing. When false, messages will use flood mode.
/// outPathLen: -1 = unknown/not learned, 0 = direct (zero hops), 1+ = multi-hop path
bool get hasPath => outPathLen >= 0 && outPathLen <= 64;
/// Get path description for UI display
String get pathDescription {
if (!hasPath) {
// -1 (0xFF) indicates path not learned yet
return 'No path (flood mode)';
}
// outPathLen = 0 means direct connection with zero hops
// outPathLen >= 1 means path with N hops
if (outPathLen == 0) {
return 'Direct (0 hops)';
} else if (outPathLen == 1) {
return 'Direct (1 hop)';
} else if (outPathLen <= 3) {
return 'Good path ($outPathLen hops)';
} else if (outPathLen <= 5) {
return 'Medium path ($outPathLen hops)';
} else {
return 'Long path ($outPathLen hops)';
}
}
/// Get path quality indicator (0-5 scale, higher is better)
/// -1 means no path (will use flood mode)
int get pathQuality {
if (!hasPath) return -1;
if (outPathLen == 0) return 5; // Direct connection (0 hops)
if (outPathLen == 1) return 4; // 1 hop
if (outPathLen <= 2) return 3; // 2 hops
if (outPathLen <= 3) return 2; // 3 hops
if (outPathLen <= 4) return 1; // 4 hops
return 0; // 5+ hops
}
/// Add a new advertisement location to history (maintains max 1000 points)
///
/// Implements location dithering to avoid storing redundant points:
/// - Only stores points that are ≥1 meter apart (max meter accuracy)
/// - Prevents trail clutter when contact is stationary or moving slowly
/// - Maintains chronological order (most recent first)
Contact addAdvertLocation(LatLng location, DateTime timestamp) {
final newPoint = AdvertLocation(location: location, timestamp: timestamp);
// Dithering: Skip points within 1 meter of the last recorded position
// This provides max meter accuracy while avoiding redundant data
if (advertHistory.isNotEmpty) {
final lastPoint = advertHistory.first;
final distance = _calculateDistance(lastPoint.location, location);
// If less than 1 meter apart, skip this point (location dithering)
if (distance < 1.0) {
return this;
}
}
// Add new point at the beginning (most recent first)
final updatedHistory = [newPoint, ...advertHistory];
// Keep only the most recent 1000 points to limit memory usage
final trimmedHistory = updatedHistory.length > 1000
? updatedHistory.sublist(0, 1000)
: updatedHistory;
return copyWith(advertHistory: trimmedHistory);
}
/// Calculate distance between two points in meters (Haversine formula)
double _calculateDistance(LatLng point1, LatLng point2) {
const double earthRadius = 6371000; // meters
final lat1 = point1.latitude * (pi / 180);
final lat2 = point2.latitude * (pi / 180);
final dLat = (point2.latitude - point1.latitude) * (pi / 180);
final dLon = (point2.longitude - point1.longitude) * (pi / 180);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1) * cos(lat2) *
sin(dLon / 2) * sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
Contact copyWith({
Uint8List? publicKey,
ContactType? type,
int? flags,
int? outPathLen,
Uint8List? outPath,
String? advName,
int? lastAdvert,
int? advLat,
int? advLon,
int? lastMod,
ContactTelemetry? telemetry,
List<AdvertLocation>? advertHistory,
bool? isNew,
}) {
return Contact(
publicKey: publicKey ?? this.publicKey,
type: type ?? this.type,
flags: flags ?? this.flags,
outPathLen: outPathLen ?? this.outPathLen,
outPath: outPath ?? this.outPath,
advName: advName ?? this.advName,
lastAdvert: lastAdvert ?? this.lastAdvert,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
lastMod: lastMod ?? this.lastMod,
telemetry: telemetry ?? this.telemetry,
advertHistory: advertHistory ?? this.advertHistory,
isNew: isNew ?? this.isNew,
);
}
@override
String toString() {
return 'Contact(name: $advName, type: ${type.displayName}, key: $publicKeyShort)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Contact &&
publicKeyHex == other.publicKeyHex;
}
@override
int get hashCode => publicKeyHex.hashCode;
}

View File

@@ -0,0 +1,76 @@
import 'package:latlong2/latlong.dart';
/// Contact telemetry data from MeshCore device
class ContactTelemetry {
final LatLng? gpsLocation;
final double? batteryPercentage;
final double? batteryMilliVolts;
final double? temperature;
final DateTime timestamp;
// Additional sensor data
final double? humidity;
final double? pressure;
final Map<String, dynamic>? extraSensorData;
ContactTelemetry({
this.gpsLocation,
this.batteryPercentage,
this.batteryMilliVolts,
this.temperature,
required this.timestamp,
this.humidity,
this.pressure,
this.extraSensorData,
});
/// Check if telemetry data is recent (within last 5 minutes)
bool get isRecent {
return DateTime.now().difference(timestamp).inMinutes < 5;
}
/// Check if battery level is low (< 20%)
bool get isLowBattery {
return batteryPercentage != null && batteryPercentage! < 20.0;
}
/// Check if battery level is critical (< 10%)
bool get isCriticalBattery {
return batteryPercentage != null && batteryPercentage! < 10.0;
}
/// Get battery status color indicator
String get batteryStatus {
if (batteryPercentage == null) return 'unknown';
if (batteryPercentage! > 50) return 'good';
if (batteryPercentage! > 20) return 'medium';
return 'low';
}
ContactTelemetry copyWith({
LatLng? gpsLocation,
double? batteryPercentage,
double? batteryMilliVolts,
double? temperature,
DateTime? timestamp,
double? humidity,
double? pressure,
Map<String, dynamic>? extraSensorData,
}) {
return ContactTelemetry(
gpsLocation: gpsLocation ?? this.gpsLocation,
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
temperature: temperature ?? this.temperature,
timestamp: timestamp ?? this.timestamp,
humidity: humidity ?? this.humidity,
pressure: pressure ?? this.pressure,
extraSensorData: extraSensorData ?? this.extraSensorData,
);
}
@override
String toString() {
return 'ContactTelemetry(gps: $gpsLocation, battery: $batteryPercentage%, temp: $temperature°C, time: $timestamp)';
}
}

282
lib/models/device_info.dart Normal file
View File

@@ -0,0 +1,282 @@
import 'dart:typed_data';
/// BLE connection state
enum ConnectionState {
disconnected,
connecting,
connected,
disconnecting,
error,
}
/// Connection mode for app operation
enum ConnectionMode {
/// Direct BLE connection to MeshCore device (default)
ble,
/// Act as SSE server - share BLE device with multiple clients
sseServer,
/// Connect to remote SSE server - no direct BLE connection
sseClient,
}
extension ConnectionModeExtension on ConnectionMode {
String get displayName {
switch (this) {
case ConnectionMode.ble:
return 'Direct (BLE)';
case ConnectionMode.sseServer:
return 'Share Device (Server)';
case ConnectionMode.sseClient:
return 'Connect to Server';
}
}
String get description {
switch (this) {
case ConnectionMode.ble:
return 'Direct BLE connection to MeshCore device';
case ConnectionMode.sseServer:
return 'Share BLE device with multiple clients over network';
case ConnectionMode.sseClient:
return 'Connect to remote server without BLE';
}
}
}
/// MeshCore device information
class DeviceInfo {
final String? deviceId;
final String? deviceName;
final ConnectionState connectionState;
final int? batteryMilliVolts;
final double? batteryPercentage;
final int? storageUsedKb;
final int? storageTotalKb;
final int? signalRssi;
final double? signalSnr;
final DateTime? lastUpdate;
// Self info from MeshCore device
final int? deviceType;
final int? txPower;
final int? maxTxPower;
final Uint8List? publicKey;
final int? advLat;
final int? advLon;
final bool? manualAddContacts;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
final int? radioCr;
final String? selfName;
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
final int? maxContacts; // Max contacts device supports
final int? maxChannels; // Max channels device supports
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
final int? blePin; // BLE PIN code
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
// Firmware info
final int? firmwareVersion;
final String? firmwareBuildDate;
final String? manufacturerModel;
final String? semanticVersion;
DeviceInfo({
this.deviceId,
this.deviceName,
this.connectionState = ConnectionState.disconnected,
this.batteryMilliVolts,
this.batteryPercentage,
this.storageUsedKb,
this.storageTotalKb,
this.signalRssi,
this.signalSnr,
this.lastUpdate,
this.deviceType,
this.txPower,
this.maxTxPower,
this.publicKey,
this.advLat,
this.advLon,
this.manualAddContacts,
this.radioFreq,
this.radioBw,
this.radioSf,
this.radioCr,
this.selfName,
this.maxContacts,
this.maxChannels,
this.telemetryModes,
this.blePin,
this.multiAcks,
this.advertLocPolicy,
this.firmwareVersion,
this.firmwareBuildDate,
this.manufacturerModel,
this.semanticVersion,
});
/// Check if device is connected
bool get isConnected => connectionState == ConnectionState.connected;
/// Check if device is connecting
bool get isConnecting => connectionState == ConnectionState.connecting;
/// Check if device has error
bool get hasError => connectionState == ConnectionState.error;
/// Get battery percentage (calculated or provided)
double? get batteryPercent {
if (batteryPercentage != null) return batteryPercentage!;
if (batteryMilliVolts == null) return null;
// Rough conversion from mV to percentage (3.0V = 0%, 4.2V = 100%)
final voltage = batteryMilliVolts! / 1000.0;
if (voltage <= 3.0) return 0.0;
if (voltage >= 4.2) return 100.0;
return ((voltage - 3.0) / 1.2) * 100.0;
}
/// Get battery status
String get batteryStatus {
final percent = batteryPercent;
if (percent == null) return 'Unknown';
if (percent > 80) return 'Excellent';
if (percent > 50) return 'Good';
if (percent > 20) return 'Low';
return 'Critical';
}
/// Get storage usage percentage (0-100)
double? get storageUsedPercent {
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
return null;
}
return (storageUsedKb! / storageTotalKb!) * 100.0;
}
/// Get storage available in KB
int? get storageAvailableKb {
if (storageUsedKb == null || storageTotalKb == null) {
return null;
}
return storageTotalKb! - storageUsedKb!;
}
/// Get human-readable storage status
String get storageStatus {
final percent = storageUsedPercent;
if (percent == null) return 'Unknown';
if (percent < 50) return 'Plenty Available';
if (percent < 80) return 'Moderate Usage';
if (percent < 95) return 'Low Space';
return 'Critical - Nearly Full';
}
/// Get signal strength category
String get signalStrength {
if (signalRssi == null) return 'Unknown';
if (signalRssi! > -60) return 'Excellent';
if (signalRssi! > -70) return 'Good';
if (signalRssi! > -80) return 'Fair';
return 'Poor';
}
/// Get public key as hex string (short)
String? get publicKeyShort {
if (publicKey == null || publicKey!.length < 8) return null;
return publicKey!
.sublist(0, 8)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
/// Get display name with "MeshCore-" prefix removed
String? get displayName {
if (deviceName == null) return null;
if (deviceName!.startsWith('MeshCore-')) {
return deviceName!.substring(9); // Remove "MeshCore-" (9 characters)
}
return deviceName;
}
DeviceInfo copyWith({
String? deviceId,
String? deviceName,
ConnectionState? connectionState,
int? batteryMilliVolts,
double? batteryPercentage,
int? storageUsedKb,
int? storageTotalKb,
int? signalRssi,
double? signalSnr,
DateTime? lastUpdate,
int? deviceType,
int? txPower,
int? maxTxPower,
Uint8List? publicKey,
int? advLat,
int? advLon,
bool? manualAddContacts,
int? radioFreq,
int? radioBw,
int? radioSf,
int? radioCr,
String? selfName,
int? maxContacts,
int? maxChannels,
int? telemetryModes,
int? blePin,
int? multiAcks,
int? advertLocPolicy,
int? firmwareVersion,
String? firmwareBuildDate,
String? manufacturerModel,
String? semanticVersion,
}) {
return DeviceInfo(
deviceId: deviceId ?? this.deviceId,
deviceName: deviceName ?? this.deviceName,
connectionState: connectionState ?? this.connectionState,
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
storageUsedKb: storageUsedKb ?? this.storageUsedKb,
storageTotalKb: storageTotalKb ?? this.storageTotalKb,
signalRssi: signalRssi ?? this.signalRssi,
signalSnr: signalSnr ?? this.signalSnr,
lastUpdate: lastUpdate ?? this.lastUpdate,
deviceType: deviceType ?? this.deviceType,
txPower: txPower ?? this.txPower,
maxTxPower: maxTxPower ?? this.maxTxPower,
publicKey: publicKey ?? this.publicKey,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,
radioCr: radioCr ?? this.radioCr,
selfName: selfName ?? this.selfName,
maxContacts: maxContacts ?? this.maxContacts,
maxChannels: maxChannels ?? this.maxChannels,
telemetryModes: telemetryModes ?? this.telemetryModes,
blePin: blePin ?? this.blePin,
multiAcks: multiAcks ?? this.multiAcks,
advertLocPolicy: advertLocPolicy ?? this.advertLocPolicy,
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate,
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion,
);
}
@override
String toString() {
return 'DeviceInfo(name: $deviceName, state: $connectionState, battery: ${batteryPercent?.toStringAsFixed(0)}%, signal: $signalRssi dBm)';
}
}

View File

@@ -0,0 +1,106 @@
import 'package:latlong2/latlong.dart';
/// Represents a single point in a location trail
class TrailPoint {
final LatLng position;
final DateTime timestamp;
final double? accuracy;
final double? speed;
TrailPoint({
required this.position,
required this.timestamp,
this.accuracy,
this.speed,
});
Map<String, dynamic> toJson() => {
'lat': position.latitude,
'lon': position.longitude,
'timestamp': timestamp.toIso8601String(),
'accuracy': accuracy,
'speed': speed,
};
factory TrailPoint.fromJson(Map<String, dynamic> json) {
return TrailPoint(
position: LatLng(json['lat'] as double, json['lon'] as double),
timestamp: DateTime.parse(json['timestamp'] as String),
accuracy: json['accuracy'] as double?,
speed: json['speed'] as double?,
);
}
}
/// Represents a location trail (breadcrumb trail) on the map
class LocationTrail {
final String id;
final List<TrailPoint> points;
final DateTime startTime;
DateTime? endTime;
bool isActive;
LocationTrail({
required this.id,
List<TrailPoint>? points,
DateTime? startTime,
this.endTime,
this.isActive = true,
}) : points = points ?? [],
startTime = startTime ?? DateTime.now();
/// Add a new point to the trail
void addPoint(TrailPoint point) {
points.add(point);
}
/// Get total distance traveled in meters
double get totalDistance {
if (points.length < 2) return 0;
final distance = Distance();
double total = 0;
for (int i = 0; i < points.length - 1; i++) {
total += distance.as(
LengthUnit.Meter,
points[i].position,
points[i + 1].position,
);
}
return total;
}
/// Get duration of the trail
Duration get duration {
if (points.isEmpty) return Duration.zero;
final end = endTime ?? DateTime.now();
return end.difference(startTime);
}
/// Get list of LatLng points for rendering
List<LatLng> get latLngPoints => points.map((p) => p.position).toList();
Map<String, dynamic> toJson() => {
'id': id,
'points': points.map((p) => p.toJson()).toList(),
'startTime': startTime.toIso8601String(),
'endTime': endTime?.toIso8601String(),
'isActive': isActive,
};
factory LocationTrail.fromJson(Map<String, dynamic> json) {
return LocationTrail(
id: json['id'] as String,
points: (json['points'] as List)
.map((p) => TrailPoint.fromJson(p as Map<String, dynamic>))
.toList(),
startTime: DateTime.parse(json['startTime'] as String),
endTime: json['endTime'] != null
? DateTime.parse(json['endTime'] as String)
: null,
isActive: json['isActive'] as bool? ?? true,
);
}
}

420
lib/models/map_drawing.dart Normal file
View File

@@ -0,0 +1,420 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
/// Drawing shape type
enum DrawingShapeType {
line,
rectangle,
}
/// Drawing color enum for compact network transmission
enum DrawingColor {
red, // 0
blue, // 1
green, // 2
yellow, // 3
orange, // 4
purple, // 5
pink, // 6
cyan, // 7
}
/// Drawing colors available for user selection
class DrawingColors {
static const List<Color> palette = [
Colors.red, // index 0
Colors.blue, // index 1
Colors.green, // index 2
Colors.yellow, // index 3
Colors.orange, // index 4
Colors.purple, // index 5
Colors.pink, // index 6
Colors.cyan, // index 7
];
static String colorToName(Color color) {
if (color == Colors.red) return 'Red';
if (color == Colors.blue) return 'Blue';
if (color == Colors.green) return 'Green';
if (color == Colors.yellow) return 'Yellow';
if (color == Colors.orange) return 'Orange';
if (color == Colors.purple) return 'Purple';
if (color == Colors.pink) return 'Pink';
if (color == Colors.cyan) return 'Cyan';
return 'Unknown';
}
/// Convert Color to enum index for network transmission
static int colorToIndex(Color color) {
for (int i = 0; i < palette.length; i++) {
if (palette[i].toARGB32() == color.toARGB32()) {
return i;
}
}
return 0; // Default to red if not found
}
/// Convert enum index to Color for network reception
static Color indexToColor(int index) {
if (index >= 0 && index < palette.length) {
return palette[index];
}
return palette[0]; // Default to red if invalid index
}
}
/// Base class for map drawings
abstract class MapDrawing {
final String id;
final DrawingShapeType type;
final Color color;
final DateTime createdAt;
final String? senderName; // Name of sender (null if local drawing)
final bool isReceived; // True if drawing was received from another node
final String? messageId; // ID of the source message (for navigation)
final bool isShared; // Whether drawing has been broadcast over mesh
final bool isSent; // Whether this is a sent drawing (vs received)
final bool isHidden; // Temporary visibility toggle (session only, not persisted)
MapDrawing({
required this.id,
required this.type,
required this.color,
required this.createdAt,
this.senderName,
this.isReceived = false,
this.messageId,
this.isShared = false,
this.isSent = false,
this.isHidden = false,
});
/// Convert to JSON for persistence
Map<String, dynamic> toJson();
/// Convert to JSON for network transmission (compact format)
/// Uses short field names and excludes createdAt to minimize message size
/// Sender will be fetched from packet metadata
Map<String, dynamic> toNetworkJson();
/// Parse network JSON (compact format)
/// senderName and messageId will be populated from packet metadata
static MapDrawing? fromNetworkJson(
Map<String, dynamic> json, {
String? senderName,
String? messageId,
}) {
final typeNum = json['t'] as int?;
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
return null;
}
try {
final type = DrawingShapeType.values[typeNum];
switch (type) {
case DrawingShapeType.line:
return LineDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
);
case DrawingShapeType.rectangle:
return RectangleDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
);
}
} catch (e) {
return null;
}
}
/// Create from JSON
static MapDrawing? fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String?;
if (typeStr == null) return null;
try {
final type = DrawingShapeType.values.firstWhere(
(e) => e.toString() == 'DrawingShapeType.$typeStr',
);
switch (type) {
case DrawingShapeType.line:
return LineDrawing.fromJson(json);
case DrawingShapeType.rectangle:
return RectangleDrawing.fromJson(json);
}
} catch (e) {
return null;
}
}
/// Get the center point of the drawing
LatLng getCenter();
/// Get the bounds of the drawing
LatLngBounds getBounds();
}
/// Line drawing on map
class LineDrawing extends MapDrawing {
final List<LatLng> points;
LineDrawing({
required super.id,
required super.color,
required super.createdAt,
required this.points,
super.senderName,
super.isReceived,
super.messageId,
super.isShared,
super.isSent,
super.isHidden,
}) : super(type: DrawingShapeType.line);
@override
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.name,
'color': color.toARGB32(),
'createdAt': createdAt.toIso8601String(),
'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
'isShared': isShared,
// Note: isHidden is not persisted - it's session-only
};
}
@override
Map<String, dynamic> toNetworkJson() {
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
// Sender is fetched from packet metadata, not included in JSON
return {
't': type.index,
'c': DrawingColors.colorToIndex(color),
'p': points.expand((p) => [
double.parse(p.latitude.toStringAsFixed(5)),
double.parse(p.longitude.toStringAsFixed(5)),
]).toList(),
};
}
static LineDrawing fromJson(Map<String, dynamic> json) {
final pointsJson = json['points'] as List<dynamic>;
final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList();
final senderName = json['sender'] as String?;
return LineDrawing(
id: json['id'] as String,
color: Color(json['color'] as int),
createdAt: DateTime.parse(json['createdAt'] as String),
points: points,
senderName: senderName,
isReceived: senderName != null, // Mark as received if sender is present
isShared: json['isShared'] as bool? ?? false,
);
}
static LineDrawing fromNetworkJson(
Map<String, dynamic> json, {
String? senderName,
String? messageId,
}) {
// Parse ultra-compact format
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
final points = <LatLng>[];
for (int i = 0; i < pointsFlat.length; i += 2) {
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
}
return LineDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
color: DrawingColors.indexToColor(json['c'] as int),
createdAt: DateTime.now(),
points: points,
senderName: senderName,
isReceived: true,
messageId: messageId, // Link to source message
isShared: false, // Received drawings are not marked as shared
);
}
/// Create a copy with updated points
LineDrawing copyWith({List<LatLng>? points}) {
return LineDrawing(
id: id,
color: color,
createdAt: createdAt,
points: points ?? this.points,
);
}
@override
LatLng getCenter() {
if (points.isEmpty) return LatLng(0, 0);
if (points.length == 1) return points[0];
// Calculate center as average of all points
double sumLat = 0;
double sumLon = 0;
for (final point in points) {
sumLat += point.latitude;
sumLon += point.longitude;
}
return LatLng(sumLat / points.length, sumLon / points.length);
}
@override
LatLngBounds getBounds() {
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0));
if (points.length == 1) return LatLngBounds(points[0], points[0]);
double minLat = points[0].latitude;
double maxLat = points[0].latitude;
double minLon = points[0].longitude;
double maxLon = points[0].longitude;
for (final point in points) {
if (point.latitude < minLat) minLat = point.latitude;
if (point.latitude > maxLat) maxLat = point.latitude;
if (point.longitude < minLon) minLon = point.longitude;
if (point.longitude > maxLon) maxLon = point.longitude;
}
return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon));
}
}
/// Rectangle drawing on map
class RectangleDrawing extends MapDrawing {
final LatLng topLeft;
final LatLng bottomRight;
RectangleDrawing({
required super.id,
required super.color,
required super.createdAt,
required this.topLeft,
required this.bottomRight,
super.senderName,
super.isReceived,
super.messageId,
super.isShared,
super.isSent,
super.isHidden,
}) : super(type: DrawingShapeType.rectangle);
/// Get all corner points for rendering
List<LatLng> get corners => [
topLeft,
LatLng(topLeft.latitude, bottomRight.longitude), // top right
bottomRight,
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left
topLeft, // close the rectangle
];
@override
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.name,
'color': color.toARGB32(),
'createdAt': createdAt.toIso8601String(),
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude},
'isShared': isShared,
// Note: isHidden is not persisted - it's session-only
};
}
@override
Map<String, dynamic> toNetworkJson() {
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
// Sender is fetched from packet metadata, not included in JSON
return {
't': type.index,
'c': DrawingColors.colorToIndex(color),
'b': [
double.parse(topLeft.latitude.toStringAsFixed(5)),
double.parse(topLeft.longitude.toStringAsFixed(5)),
double.parse(bottomRight.latitude.toStringAsFixed(5)),
double.parse(bottomRight.longitude.toStringAsFixed(5)),
],
};
}
static RectangleDrawing fromJson(Map<String, dynamic> json) {
final topLeftJson = json['topLeft'] as Map<String, dynamic>;
final bottomRightJson = json['bottomRight'] as Map<String, dynamic>;
final senderName = json['sender'] as String?;
return RectangleDrawing(
id: json['id'] as String,
color: Color(json['color'] as int),
createdAt: DateTime.parse(json['createdAt'] as String),
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double),
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double),
senderName: senderName,
isReceived: senderName != null, // Mark as received if sender is present
isShared: json['isShared'] as bool? ?? false,
);
}
static RectangleDrawing fromNetworkJson(
Map<String, dynamic> json, {
String? senderName,
String? messageId,
}) {
// Parse ultra-compact format
final bounds = (json['b'] as List<dynamic>).cast<double>();
return RectangleDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
color: DrawingColors.indexToColor(json['c'] as int),
createdAt: DateTime.now(),
topLeft: LatLng(bounds[0], bounds[1]),
bottomRight: LatLng(bounds[2], bounds[3]),
senderName: senderName,
isReceived: true,
messageId: messageId, // Link to source message
isShared: false, // Received drawings are not marked as shared
);
}
/// Create a copy with updated corners
RectangleDrawing copyWith({
LatLng? topLeft,
LatLng? bottomRight,
}) {
return RectangleDrawing(
id: id,
color: color,
createdAt: createdAt,
topLeft: topLeft ?? this.topLeft,
bottomRight: bottomRight ?? this.bottomRight,
);
}
@override
LatLng getCenter() {
// Center is the midpoint between top-left and bottom-right
return LatLng(
(topLeft.latitude + bottomRight.latitude) / 2,
(topLeft.longitude + bottomRight.longitude) / 2,
);
}
@override
LatLngBounds getBounds() {
// Bounds are simply the two corners
return LatLngBounds(topLeft, bottomRight);
}
}

209
lib/models/map_layer.dart Normal file
View File

@@ -0,0 +1,209 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../l10n/app_localizations.dart';
enum MapLayerType {
openStreetMap,
openTopoMap,
esriWorldImagery,
googleHybrid,
googleRoadmap,
googleTerrain,
vectorMbtiles,
wmsBase,
}
class MapLayer {
final MapLayerType type;
final String name;
final String urlTemplate;
final String attribution;
final double maxZoom;
// Vector tile specific properties
final bool isVector;
final File? mbtilesFile;
final String? styleUrl;
final String? sourceName;
final bool? isGzipped;
// WMS specific properties
final bool isWms;
final String? wmsBaseUrl;
final List<String>? wmsLayers;
final String? wmsFormat;
final bool? wmsTransparent;
final List<String>? wmsStyles;
final Crs? crs;
const MapLayer({
required this.type,
required this.name,
required this.urlTemplate,
required this.attribution,
required this.maxZoom,
this.isVector = false,
this.mbtilesFile,
this.styleUrl,
this.sourceName,
this.isGzipped,
this.isWms = false,
this.wmsBaseUrl,
this.wmsLayers,
this.wmsFormat,
this.wmsTransparent,
this.wmsStyles,
this.crs,
});
/// Get localized name for the layer
String getLocalizedName(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
switch (type) {
case MapLayerType.openStreetMap:
return localizations.openStreetMap;
case MapLayerType.openTopoMap:
return localizations.openTopoMap;
case MapLayerType.esriWorldImagery:
return localizations.esriSatellite;
case MapLayerType.googleHybrid:
return localizations.googleHybrid;
case MapLayerType.googleRoadmap:
return localizations.googleRoadmap;
case MapLayerType.googleTerrain:
return localizations.googleTerrain;
case MapLayerType.vectorMbtiles:
// For vector tiles, use the name from metadata
return name;
case MapLayerType.wmsBase:
// For WMS layers, use the name (will be localized separately)
return name;
}
}
static const openStreetMap = MapLayer(
type: MapLayerType.openStreetMap,
name: 'OpenStreetMap',
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
maxZoom: 19, // OSM standard maximum
);
static const openTopoMap = MapLayer(
type: MapLayerType.openTopoMap,
name: 'OpenTopoMap',
urlTemplate: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
attribution: '© OpenTopoMap (CC-BY-SA)',
maxZoom: 17.49, // OpenTopoMap maximum (just below level 18)
);
static const esriWorldImagery = MapLayer(
type: MapLayerType.esriWorldImagery,
name: 'ESRI Satellite',
urlTemplate:
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri',
maxZoom: 19, // ESRI World Imagery maximum
);
static const googleHybrid = MapLayer(
type: MapLayerType.googleHybrid,
name: 'Google Hybrid',
urlTemplate: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}',
attribution: '© Google',
maxZoom: 20, // Google Maps maximum
);
static const googleRoadmap = MapLayer(
type: MapLayerType.googleRoadmap,
name: 'Google Roadmap',
urlTemplate: 'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}',
attribution: '© Google',
maxZoom: 20, // Google Maps maximum
);
static const googleTerrain = MapLayer(
type: MapLayerType.googleTerrain,
name: 'Google Terrain',
urlTemplate: 'http://mt0.google.com/vt/lyrs=p&hl=en&x={x}&y={y}&z={z}',
attribution: '© Google',
maxZoom: 20, // Google Maps maximum
);
/// Slovenian Aerial Imagery 2024 (Ortofoto) - WMS Base Layer
/// Uses EPSG:3794 coordinate system (GeoWebCache tile matrix zoom 0-15)
/// Note: CRS is initialized at runtime in getSlovenianAerial2024()
static MapLayer getSlovenianAerial2024(Crs slovenianCrs) {
return MapLayer(
type: MapLayerType.wmsBase,
name: 'Ortofoto 2024 (Slovenija)',
urlTemplate: '', // Not used for WMS
attribution: '© GURS (Geodetska uprava Republike Slovenije)',
maxZoom: 15, // GeoWebCache tile matrix maximum
isWms: true,
wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?',
wmsLayers: const ['pregledovalnik:DOF_2024'],
wmsFormat: 'image/jpeg',
wmsTransparent: false,
crs: slovenianCrs,
);
}
/// Slovenian Topographic Map 1:25000 (DTK25) - WMS Base Layer
/// Uses EPSG:3794 coordinate system (GeoWebCache tile matrix zoom 0-15)
/// Note: CRS is initialized at runtime in getDTK25()
static MapLayer getDTK25(Crs slovenianCrs) {
return MapLayer(
type: MapLayerType.wmsBase,
name: 'DTK25 (Slovenija)',
urlTemplate: '', // Not used for WMS
attribution: '© GURS (Geodetska uprava Republike Slovenije)',
maxZoom: 15, // GeoWebCache tile matrix maximum
isWms: true,
wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?',
wmsLayers: const ['pregledovalnik:DTK25'],
wmsFormat: 'image/jpeg',
wmsTransparent: false,
crs: slovenianCrs,
);
}
static const List<MapLayer> allLayers = [
openStreetMap,
openTopoMap,
esriWorldImagery,
googleHybrid,
googleRoadmap,
googleTerrain,
// Note: Slovenian aerial layer is added dynamically via getSlovenianAerial2024()
];
static MapLayer fromType(MapLayerType type) {
return allLayers.firstWhere((layer) => layer.type == type);
}
/// Create a MapLayer from an MBTiles file
static MapLayer fromMbtilesFile({
required String name,
required File mbtilesFile,
required String styleUrl,
required String sourceName,
required double maxZoom,
required bool isGzipped,
String? attribution,
}) {
return MapLayer(
type: MapLayerType.vectorMbtiles,
name: name,
urlTemplate: '', // Not used for vector tiles
attribution: attribution ?? 'MBTiles',
maxZoom: maxZoom,
isVector: true,
mbtilesFile: mbtilesFile,
styleUrl: styleUrl,
sourceName: sourceName,
isGzipped: isGzipped,
);
}
}

491
lib/models/message.dart Normal file
View File

@@ -0,0 +1,491 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'sar_marker.dart';
/// Message recipient tracking for grouped messages
class MessageRecipient {
final Uint8List publicKey; // Full public key
final String displayName; // Contact display name
final MessageDeliveryStatus deliveryStatus;
final int? expectedAckTag;
final int? roundTripTimeMs;
final DateTime? deliveredAt;
final DateTime sentAt;
const MessageRecipient({
required this.publicKey,
required this.displayName,
required this.deliveryStatus,
this.expectedAckTag,
this.roundTripTimeMs,
this.deliveredAt,
required this.sentAt,
});
MessageRecipient copyWith({
Uint8List? publicKey,
String? displayName,
MessageDeliveryStatus? deliveryStatus,
int? expectedAckTag,
int? roundTripTimeMs,
DateTime? deliveredAt,
DateTime? sentAt,
}) {
return MessageRecipient(
publicKey: publicKey ?? this.publicKey,
displayName: displayName ?? this.displayName,
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
expectedAckTag: expectedAckTag ?? this.expectedAckTag,
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
deliveredAt: deliveredAt ?? this.deliveredAt,
sentAt: sentAt ?? this.sentAt,
);
}
String get publicKeyShort {
return publicKey
.sublist(0, publicKey.length < 6 ? publicKey.length : 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
}
/// Message text types from MeshCore protocol
enum MessageTextType {
plain(0),
cliData(1),
signedPlain(2);
const MessageTextType(this.value);
final int value;
static MessageTextType fromValue(int value) {
return MessageTextType.values.firstWhere(
(e) => e.value == value,
orElse: () => MessageTextType.plain,
);
}
}
/// Message type (contact, channel, or system)
enum MessageType {
contact,
channel,
system, // System messages (log entries, status updates)
}
/// Message delivery status
enum MessageDeliveryStatus {
sending, // Message is being sent
sent, // Message queued with expected ACK
delivered, // Delivery confirmed (ACK received)
failed, // Delivery failed
received, // Message received from another contact
}
/// MeshCore message model
class Message {
final String id;
final MessageType messageType;
final Uint8List? senderPublicKeyPrefix; // 6 bytes for contact messages
final int? channelIdx; // For channel messages
final int pathLen;
final MessageTextType textType;
final int senderTimestamp; // Unix timestamp
final String text;
// SAR marker data (if this is a SAR message)
final bool isSarMarker;
final LatLng? sarGpsCoordinates;
final String? sarNotes; // Optional message/notes for SAR marker
final String? sarCustomEmoji; // Custom emoji for unknown SAR marker types
final int? sarColorIndex; // Color index (0-7) from standard palette
// Display metadata
final DateTime receivedAt;
final String? senderName;
// Delivery tracking (for sent messages)
final MessageDeliveryStatus deliveryStatus;
final int? expectedAckTag; // Expected ACK/TAG from SENT response
final int? suggestedTimeoutMs; // Suggested timeout from SENT response
final int? roundTripTimeMs; // RTT from SEND_CONFIRMED
final DateTime? deliveredAt; // When delivery was confirmed
final Uint8List?
recipientPublicKey; // Full 32-byte public key of recipient (for retry)
// Retry tracking (for automatic retry with progressive timeouts)
final int retryAttempt; // Current retry attempt (0-3), 0 = first send
final DateTime? lastRetryAt; // When last retry was sent
final bool
usedFloodFallback; // Whether message fell back to flood mode after retries
// Read status tracking
final bool isRead; // Whether message has been read by user
// Echo detection for public channel messages
final int echoCount; // Number of times message was detected being rebroadcast
final DateTime? firstEchoAt; // When first echo was detected
// Drawing message tracking
final bool isDrawing; // Whether this message contains a map drawing
final String? drawingId; // ID of the associated drawing (for navigation)
// Message grouping for bulk sends (same message to multiple recipients)
final String? groupId; // Shared ID for messages in the same bulk send
final List<MessageRecipient>?
recipients; // List of recipients (for group leader message)
Message({
required this.id,
required this.messageType,
this.senderPublicKeyPrefix,
this.channelIdx,
required this.pathLen,
required this.textType,
required this.senderTimestamp,
required this.text,
this.isSarMarker = false,
this.sarGpsCoordinates,
this.sarNotes,
this.sarCustomEmoji,
this.sarColorIndex,
required this.receivedAt,
this.senderName,
this.deliveryStatus = MessageDeliveryStatus.received,
this.expectedAckTag,
this.suggestedTimeoutMs,
this.roundTripTimeMs,
this.deliveredAt,
this.recipientPublicKey,
this.retryAttempt = 0,
this.lastRetryAt,
this.usedFloodFallback = false,
this.isRead = false,
this.echoCount = 0,
this.firstEchoAt,
this.isDrawing = false,
this.drawingId,
this.groupId,
this.recipients,
});
/// Get SAR marker type by inferring from message content
/// Returns the type inferred from sarCustomEmoji or by parsing the message text
SarMarkerType? get sarMarkerType {
if (!isSarMarker) return null;
// If we have a custom emoji stored, infer type from it
if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) {
return SarMarkerType.fromEmoji(sarCustomEmoji!);
}
// Otherwise, parse the message text to extract the emoji
final trimmed = text.trim();
if (!trimmed.startsWith('S:')) return null;
// Extract emoji from format: S:<emoji>:... or S:<emoji>:<colorIndex>:...
final parts = trimmed.split(':');
if (parts.length < 3) return null;
final emoji = parts[1];
return SarMarkerType.fromEmoji(emoji);
}
/// Get sender public key as hex string
String? get senderKeyShort {
if (senderPublicKeyPrefix == null) return null;
return senderPublicKeyPrefix!
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
/// Get sender timestamp as DateTime
DateTime get sentAt {
return DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000);
}
/// Check if message is from a channel
bool get isChannelMessage => messageType == MessageType.channel;
/// Check if message is from a contact
bool get isContactMessage => messageType == MessageType.contact;
/// Check if message is a system message
bool get isSystemMessage => messageType == MessageType.system;
/// Get friendly time since message was sent
String get timeAgo {
final diff = DateTime.now().difference(sentAt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
/// Get display name for sender (basic fallback without contact info)
String get displaySender {
if (senderName != null && senderName!.isNotEmpty) {
return senderName!;
}
if (senderKeyShort != null) {
return senderKeyShort!.substring(0, 8);
}
if (isChannelMessage && channelIdx != null) {
return 'Channel $channelIdx';
}
return 'Unknown';
}
/// Get rich display name for sender using contact information
/// Returns emoji + display name if available, otherwise falls back to displaySender
String getRichDisplayName(dynamic contact) {
if (contact == null) return displaySender;
// If contact has roleEmoji, use it with displayName
final roleEmoji = contact.roleEmoji;
if (roleEmoji != null && roleEmoji.isNotEmpty) {
return '$roleEmoji ${contact.displayName}';
}
// Otherwise just use advName or displayName
return contact.displayName ?? contact.advName ?? displaySender;
}
/// Convert to SAR marker if applicable
SarMarker? toSarMarker() {
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
return null;
}
// Debug: Check what's in sarNotes
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
debugPrint(' message.text: "$text"');
debugPrint(' message.sarNotes: "$sarNotes"');
debugPrint(' message.sarMarkerType: $sarMarkerType');
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
return SarMarker(
id: id,
type: sarMarkerType!,
location: sarGpsCoordinates!,
timestamp: sentAt,
senderPublicKey: senderPublicKeyPrefix,
senderName: senderName,
notes: sarNotes, // Use dedicated notes field instead of full text
customEmoji: sarCustomEmoji, // Preserve custom emoji for unknown types
colorIndex: sarColorIndex, // Pass through color index
);
}
/// Get echo status text for channel messages
String get echoStatusText {
if (!isChannelMessage) return '';
if (echoCount == 0) {
return 'Broadcast (no echoes)';
} else if (echoCount == 1) {
return 'Rebroadcast by 1 node';
} else {
return 'Rebroadcast by $echoCount nodes';
}
}
/// Get friendly delivery status description
String get deliveryStatusText {
// For channel messages, show echo status instead
if (isChannelMessage && isSentMessage) {
return echoStatusText;
}
switch (deliveryStatus) {
case MessageDeliveryStatus.sending:
if (retryAttempt > 0) {
return 'Retrying ($retryAttempt/3)...';
}
return 'Sending...';
case MessageDeliveryStatus.sent:
if (retryAttempt > 0) {
return 'Sent (retry $retryAttempt)';
}
return 'Sent';
case MessageDeliveryStatus.delivered:
final rttText = roundTripTimeMs != null ? '${roundTripTimeMs}ms' : '';
if (retryAttempt > 0 && rttText.isNotEmpty) {
return 'Delivered ($rttText) [retry $retryAttempt]';
} else if (retryAttempt > 0) {
return 'Delivered [retry $retryAttempt]';
} else if (rttText.isNotEmpty) {
return 'Delivered ($rttText)';
}
return 'Delivered';
case MessageDeliveryStatus.failed:
if (usedFloodFallback) {
return 'Failed (tried flood)';
}
if (retryAttempt > 0) {
final retryWord = retryAttempt == 1 ? 'retry' : 'retries';
return 'Failed (after $retryAttempt $retryWord)';
}
return 'Failed';
case MessageDeliveryStatus.received:
return '';
}
}
/// Check if this is a sent message (not received)
bool get isSentMessage => deliveryStatus != MessageDeliveryStatus.received;
/// Check if this message is from self (own message)
/// [selfPublicKey] - the device's own public key (first 6 bytes)
bool isFromSelf(Uint8List? selfPublicKey) {
if (selfPublicKey == null || selfPublicKey.length < 6) return false;
// Compare sender public key prefix with self public key prefix
if (senderPublicKeyPrefix != null && senderPublicKeyPrefix!.length >= 6) {
return senderPublicKeyPrefix![0] == selfPublicKey[0] &&
senderPublicKeyPrefix![1] == selfPublicKey[1] &&
senderPublicKeyPrefix![2] == selfPublicKey[2] &&
senderPublicKeyPrefix![3] == selfPublicKey[3] &&
senderPublicKeyPrefix![4] == selfPublicKey[4] &&
senderPublicKeyPrefix![5] == selfPublicKey[5];
}
return false;
}
/// Get drawing metadata from message text (returns null if not a drawing)
/// Extracts basic info for display in message bubbles
Map<String, dynamic>? get drawingMetadata {
if (!isDrawing || !text.startsWith('D:')) return null;
try {
// Return basic metadata (actual parsing happens in DrawingMessageParser)
return {'hasDrawing': true, 'drawingId': drawingId};
} catch (e) {
return null;
}
}
Message copyWith({
String? id,
MessageType? messageType,
Uint8List? senderPublicKeyPrefix,
int? channelIdx,
int? pathLen,
MessageTextType? textType,
int? senderTimestamp,
String? text,
bool? isSarMarker,
LatLng? sarGpsCoordinates,
String? sarNotes,
String? sarCustomEmoji,
int? sarColorIndex,
DateTime? receivedAt,
String? senderName,
MessageDeliveryStatus? deliveryStatus,
int? expectedAckTag,
int? suggestedTimeoutMs,
int? roundTripTimeMs,
DateTime? deliveredAt,
Uint8List? recipientPublicKey,
int? retryAttempt,
DateTime? lastRetryAt,
bool? usedFloodFallback,
bool? isRead,
int? echoCount,
DateTime? firstEchoAt,
bool? isDrawing,
String? drawingId,
String? groupId,
List<MessageRecipient>? recipients,
}) {
return Message(
id: id ?? this.id,
messageType: messageType ?? this.messageType,
senderPublicKeyPrefix:
senderPublicKeyPrefix ?? this.senderPublicKeyPrefix,
channelIdx: channelIdx ?? this.channelIdx,
pathLen: pathLen ?? this.pathLen,
textType: textType ?? this.textType,
senderTimestamp: senderTimestamp ?? this.senderTimestamp,
text: text ?? this.text,
isSarMarker: isSarMarker ?? this.isSarMarker,
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
sarNotes: sarNotes ?? this.sarNotes,
sarCustomEmoji: sarCustomEmoji ?? this.sarCustomEmoji,
sarColorIndex: sarColorIndex ?? this.sarColorIndex,
receivedAt: receivedAt ?? this.receivedAt,
senderName: senderName ?? this.senderName,
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
expectedAckTag: expectedAckTag ?? this.expectedAckTag,
suggestedTimeoutMs: suggestedTimeoutMs ?? this.suggestedTimeoutMs,
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
deliveredAt: deliveredAt ?? this.deliveredAt,
recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey,
retryAttempt: retryAttempt ?? this.retryAttempt,
lastRetryAt: lastRetryAt ?? this.lastRetryAt,
usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback,
isRead: isRead ?? this.isRead,
echoCount: echoCount ?? this.echoCount,
firstEchoAt: firstEchoAt ?? this.firstEchoAt,
isDrawing: isDrawing ?? this.isDrawing,
drawingId: drawingId ?? this.drawingId,
groupId: groupId ?? this.groupId,
recipients: recipients ?? this.recipients,
);
}
/// Check if this is a grouped message (sent to multiple recipients)
bool get isGroupedMessage =>
groupId != null && recipients != null && recipients!.isNotEmpty;
/// Get count of recipients who have received/delivered the message
int get deliveredRecipientsCount {
if (recipients == null) return 0;
return recipients!
.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered)
.length;
}
/// Get count of recipients who are pending (sending/sent)
int get pendingRecipientsCount {
if (recipients == null) return 0;
return recipients!
.where(
(r) =>
r.deliveryStatus == MessageDeliveryStatus.sending ||
r.deliveryStatus == MessageDeliveryStatus.sent,
)
.length;
}
/// Get count of recipients who failed to receive
int get failedRecipientsCount {
if (recipients == null) return 0;
return recipients!
.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed)
.length;
}
@override
String toString() {
if (isSarMarker) {
return 'Message(SAR: ${sarMarkerType?.displayName}, from: $displaySender)';
}
return 'Message(from: $displaySender, text: ${text.length > 30 ? '${text.substring(0, 30)}...' : text})';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Message && id == other.id;
}
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,111 @@
import 'dart:typed_data';
/// Represents the login state for a room
class RoomLoginState {
final Uint8List publicKeyPrefix;
final bool isLoggedIn;
final bool isAdmin;
final int permissions;
final int? tag;
final DateTime? loginTime;
final bool hasPassword; // Whether we have a saved password
const RoomLoginState({
required this.publicKeyPrefix,
this.isLoggedIn = false,
this.isAdmin = false,
this.permissions = 0,
this.tag,
this.loginTime,
this.hasPassword = false,
});
/// Create a logged-in state
factory RoomLoginState.loggedIn({
required Uint8List publicKeyPrefix,
required int permissions,
required bool isAdmin,
required int tag,
required bool hasPassword,
}) {
return RoomLoginState(
publicKeyPrefix: publicKeyPrefix,
isLoggedIn: true,
isAdmin: isAdmin,
permissions: permissions,
tag: tag,
loginTime: DateTime.now(),
hasPassword: hasPassword,
);
}
/// Create a logged-out state
factory RoomLoginState.loggedOut({
required Uint8List publicKeyPrefix,
bool hasPassword = false,
}) {
return RoomLoginState(
publicKeyPrefix: publicKeyPrefix,
isLoggedIn: false,
hasPassword: hasPassword,
);
}
/// Copy with modified fields
RoomLoginState copyWith({
Uint8List? publicKeyPrefix,
bool? isLoggedIn,
bool? isAdmin,
int? permissions,
int? tag,
DateTime? loginTime,
bool? hasPassword,
}) {
return RoomLoginState(
publicKeyPrefix: publicKeyPrefix ?? this.publicKeyPrefix,
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
isAdmin: isAdmin ?? this.isAdmin,
permissions: permissions ?? this.permissions,
tag: tag ?? this.tag,
loginTime: loginTime ?? this.loginTime,
hasPassword: hasPassword ?? this.hasPassword,
);
}
/// Get formatted public key prefix (e.g., "15:59:89:54:b4:d4")
String get publicKeyPrefixHex {
return publicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
}
/// Get login duration if logged in
Duration? get loginDuration {
if (!isLoggedIn || loginTime == null) return null;
return DateTime.now().difference(loginTime!);
}
/// Get formatted login duration (e.g., "2h 15m ago")
String? get loginDurationFormatted {
final duration = loginDuration;
if (duration == null) return null;
if (duration.inMinutes < 1) {
return 'just now';
} else if (duration.inMinutes < 60) {
return '${duration.inMinutes}m ago';
} else if (duration.inHours < 24) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
return minutes > 0 ? '${hours}h ${minutes}m ago' : '${hours}h ago';
} else {
final days = duration.inDays;
return '${days}d ago';
}
}
@override
String toString() {
return 'RoomLoginState(prefix: $publicKeyPrefixHex, loggedIn: $isLoggedIn, admin: $isAdmin, hasPassword: $hasPassword)';
}
}

197
lib/models/sar_marker.dart Normal file
View File

@@ -0,0 +1,197 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../l10n/app_localizations.dart';
import '../services/sar_template_service.dart';
/// SAR (Search & Rescue) marker types
enum SarMarkerType {
foundPerson('🧑', 'Found Person'),
fire('🔥', 'Fire'),
stagingArea('🏕️', 'Staging Area'),
object('📦', 'Object'),
unknown('', 'Unknown');
const SarMarkerType(this.emoji, this.displayName);
final String emoji;
final String displayName;
/// Get localized display name
String getLocalizedName(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
switch (this) {
case SarMarkerType.foundPerson:
return l10n.sarMarkerFoundPerson;
case SarMarkerType.fire:
return l10n.sarMarkerFire;
case SarMarkerType.stagingArea:
return l10n.sarMarkerStagingArea;
case SarMarkerType.object:
return l10n.sarMarkerObject;
case SarMarkerType.unknown:
return 'Unknown';
}
}
static SarMarkerType fromEmoji(String emoji) {
switch (emoji) {
case '🧑':
case '👤':
return SarMarkerType.foundPerson;
case '🔥':
return SarMarkerType.fire;
case '🏕️':
case '':
return SarMarkerType.stagingArea;
case '📦':
return SarMarkerType.object;
default:
return SarMarkerType.unknown;
}
}
/// Get map marker color
String get markerColor {
switch (this) {
case SarMarkerType.foundPerson:
return '#4CAF50'; // Green
case SarMarkerType.fire:
return '#F44336'; // Red
case SarMarkerType.stagingArea:
return '#2196F3'; // Blue
case SarMarkerType.object:
return '#9C27B0'; // Purple
default:
return '#9E9E9E'; // Gray
}
}
}
/// SAR marker from special messages
class SarMarker {
final String id;
final SarMarkerType type;
final LatLng location;
final DateTime timestamp;
final Uint8List? senderPublicKey;
final String? senderName;
final String? notes;
final String? customEmoji; // For custom SAR markers not in predefined types
final int? colorIndex; // Color index (0-7) from standard palette
SarMarker({
required this.id,
required this.type,
required this.location,
required this.timestamp,
this.senderPublicKey,
this.senderName,
this.notes,
this.customEmoji,
this.colorIndex,
});
/// Get sender public key as hex string (short)
String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
return senderPublicKey!
.sublist(0, 8)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
/// Get friendly time since marker was created
String get timeAgo {
final diff = DateTime.now().difference(timestamp);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
/// Check if marker is recent (within last hour)
bool get isRecent {
return DateTime.now().difference(timestamp).inHours < 1;
}
/// Get the emoji to display (custom emoji if available, otherwise type emoji)
String get emoji {
return customEmoji ?? type.emoji;
}
/// Get display name - uses notes if available, otherwise looks up template by emoji, otherwise type name
String get displayName {
if (notes != null && notes!.isNotEmpty) {
return notes!;
}
// If no notes and we have a custom emoji, try to look up the template
if (customEmoji != null) {
// Import the service here to avoid circular dependencies
// We'll use a static lookup method
return _lookupTemplateNameByEmoji(customEmoji!) ?? type.displayName;
}
return type.displayName;
}
/// Look up template name by emoji from SarTemplateService
static String? _lookupTemplateNameByEmoji(String emoji) {
try {
// Use the singleton instance
final service = SarTemplateService();
if (!service.isInitialized) {
return null;
}
// Find template with matching emoji
final template = service.templates.firstWhere(
(t) => t.emoji == emoji,
orElse: () => throw StateError('No template found'),
);
return template.name;
} catch (e) {
// Template not found or service not initialized
return null;
}
}
SarMarker copyWith({
String? id,
SarMarkerType? type,
LatLng? location,
DateTime? timestamp,
Uint8List? senderPublicKey,
String? senderName,
String? notes,
String? customEmoji,
int? colorIndex,
}) {
return SarMarker(
id: id ?? this.id,
type: type ?? this.type,
location: location ?? this.location,
timestamp: timestamp ?? this.timestamp,
senderPublicKey: senderPublicKey ?? this.senderPublicKey,
senderName: senderName ?? this.senderName,
notes: notes ?? this.notes,
customEmoji: customEmoji ?? this.customEmoji,
colorIndex: colorIndex ?? this.colorIndex,
);
}
@override
String toString() {
return 'SarMarker(type: ${type.displayName}, location: $location, sender: $senderName, time: $timeAgo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SarMarker && id == other.id;
}
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
/// SAR Template - Customizable template for SAR (Cursor on Target) messages
class SarTemplate {
final String id;
final String emoji;
final String name;
final String description;
final String colorHex;
final bool isDefault;
/// Standard color palette for SAR markers (index 0-7)
/// This palette is used for transmission to ensure consistent colors across devices
static const List<String> colorPalette = [
'#F44336', // 0 - Red
'#2196F3', // 1 - Blue
'#4CAF50', // 2 - Green
'#FFC107', // 3 - Yellow
'#FF9800', // 4 - Orange
'#9C27B0', // 5 - Purple
'#E91E63', // 6 - Pink
'#00BCD4', // 7 - Cyan
];
SarTemplate({
required this.id,
required this.emoji,
required this.name,
required this.description,
required this.colorHex,
this.isDefault = false,
});
/// Get color from hex string
Color get color {
final hexCode = colorHex.replaceAll('#', '');
return Color(int.parse('FF$hexCode', radix: 16));
}
/// Get localized display name for this template
/// Returns localized name for default templates, or the stored name for custom templates
String getLocalizedName(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (l10n == null) return name;
// Return localized names for default templates
switch (id) {
case 'default_found_person':
return l10n.sarMarkerFoundPerson;
case 'default_fire':
return l10n.sarMarkerFire;
case 'default_staging_area':
return l10n.sarMarkerStagingArea;
case 'default_object':
return l10n.sarMarkerObject;
default:
// For custom templates, return the stored name
return name;
}
}
/// Get the closest color index from the standard palette
/// Returns 0-7 for standard colors, or the closest match
int getColorIndex() {
// Normalize both colors to uppercase for comparison
final normalizedColorHex = colorHex.toUpperCase();
// Check for exact match first
for (int i = 0; i < colorPalette.length; i++) {
if (colorPalette[i].toUpperCase() == normalizedColorHex) {
return i;
}
}
// If no exact match, find closest color by calculating distance
// Parse RGB values
final hexCode = colorHex.replaceAll('#', '');
final r = int.parse(hexCode.substring(0, 2), radix: 16);
final g = int.parse(hexCode.substring(2, 4), radix: 16);
final b = int.parse(hexCode.substring(4, 6), radix: 16);
int closestIndex = 0;
double minDistance = double.infinity;
for (int i = 0; i < colorPalette.length; i++) {
final paletteHex = colorPalette[i].replaceAll('#', '');
final pr = int.parse(paletteHex.substring(0, 2), radix: 16);
final pg = int.parse(paletteHex.substring(2, 4), radix: 16);
final pb = int.parse(paletteHex.substring(4, 6), radix: 16);
// Calculate Euclidean distance in RGB space
final distance = ((r - pr) * (r - pr) + (g - pg) * (g - pg) + (b - pb) * (b - pb)).toDouble();
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
/// Get color hex from palette index
static String getColorFromIndex(int index) {
if (index < 0 || index >= colorPalette.length) {
return '#9E9E9E'; // Gray for invalid index
}
return colorPalette[index];
}
/// Create from JSON
factory SarTemplate.fromJson(Map<String, dynamic> json) {
return SarTemplate(
id: json['id'] as String,
emoji: json['emoji'] as String,
name: json['name'] as String,
description: json['description'] as String? ?? '',
colorHex: json['colorHex'] as String,
isDefault: json['isDefault'] as bool? ?? false,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'emoji': emoji,
'name': name,
'description': description,
'colorHex': colorHex,
'isDefault': isDefault,
};
}
/// Create from SAR message format (S:emoji:0,0:description)
/// Example: S:🧑:0,0:Person found
factory SarTemplate.fromSarMessage(String message) {
final trimmed = message.trim();
if (!trimmed.startsWith('S:')) {
throw FormatException('SAR message must start with "S:"');
}
// Parse format: S:emoji:lat,lon:description
final parts = trimmed.split(':');
if (parts.length < 3) {
throw FormatException('Invalid SAR message format');
}
final emoji = parts[1].trim();
if (emoji.isEmpty) {
throw FormatException('Emoji cannot be empty');
}
// Extract description (everything after the third colon)
String description = '';
if (parts.length > 3) {
description = parts.sublist(3).join(':').trim();
}
// Generate ID from emoji + description
final id = '${emoji}_${DateTime.now().millisecondsSinceEpoch}';
// Auto-assign color based on emoji
String colorHex = _getColorForEmoji(emoji);
return SarTemplate(
id: id,
emoji: emoji,
name: description.isNotEmpty ? description : emoji,
description: description,
colorHex: colorHex,
isDefault: false,
);
}
/// Convert to SAR message format with placeholder coordinates
/// New format: S:emoji:colorIndex:0,0:description
/// Example: S:🧑:2:0,0:Person found (2 = Green)
String toSarMessage() {
final colorIndex = getColorIndex();
if (description.isNotEmpty) {
return 'S:$emoji:$colorIndex:0,0:$description';
}
return 'S:$emoji:$colorIndex:0,0';
}
/// Auto-assign color based on emoji (uses standard color palette)
static String _getColorForEmoji(String emoji) {
// Default emoji to color mapping using standard palette
final colorMap = {
// Green (index 2) - Person, Safe, Nature
'🧑': colorPalette[2],
'👤': colorPalette[2],
'': colorPalette[2],
'🌲': colorPalette[2],
// Red (index 0) - Fire, Hazard, Medical, Emergency
'🔥': colorPalette[0],
'🚒': colorPalette[0],
'🚑': colorPalette[0],
'': colorPalette[0],
'🏥': colorPalette[0],
// Orange (index 4) - Staging, Assembly
'🏕️': colorPalette[4],
'': colorPalette[4],
// Purple (index 5) - Objects
'📦': colorPalette[5],
// Blue (index 1) - Water, Air support
'🚁': colorPalette[1],
'💧': colorPalette[1],
// Yellow (index 3) - Warning, Caution
'⚠️': colorPalette[3],
};
return colorMap[emoji] ?? '#9E9E9E'; // Default gray for unknown emojis
}
/// Copy with modifications
SarTemplate copyWith({
String? id,
String? emoji,
String? name,
String? description,
String? colorHex,
bool? isDefault,
}) {
return SarTemplate(
id: id ?? this.id,
emoji: emoji ?? this.emoji,
name: name ?? this.name,
description: description ?? this.description,
colorHex: colorHex ?? this.colorHex,
isDefault: isDefault ?? this.isDefault,
);
}
@override
String toString() {
return 'SarTemplate(id: $id, emoji: $emoji, name: $name, description: $description)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SarTemplate && id == other.id;
}
@override
int get hashCode => id.hashCode;
/// Default templates (uses standard color palette)
/// Colors reference:
/// - 0 Red (#F44336) - Fire, Hazard, Medical
/// - 1 Blue (#2196F3) - Water, Helicopter
/// - 2 Green (#4CAF50) - Found Person, Safe
/// - 3 Yellow (#FFC107) - Warning
/// - 4 Orange (#FF9800) - Staging Area
/// - 5 Purple (#9C27B0) - Object
/// - 6 Pink (#E91E63) - Reserved
/// - 7 Cyan (#00BCD4) - Reserved
static List<SarTemplate> get defaults {
return [
SarTemplate(
id: 'default_found_person',
emoji: '🧑',
name: 'Found Person',
description: '',
colorHex: colorPalette[2], // Green
isDefault: true,
),
SarTemplate(
id: 'default_fire',
emoji: '🔥',
name: 'Fire',
description: '',
colorHex: colorPalette[0], // Red
isDefault: true,
),
SarTemplate(
id: 'default_staging_area',
emoji: '🏕️',
name: 'Staging Area',
description: '',
colorHex: colorPalette[4], // Orange
isDefault: true,
),
SarTemplate(
id: 'default_object',
emoji: '📦',
name: 'Object',
description: '',
colorHex: colorPalette[5], // Purple
isDefault: true,
),
];
}
}

View File

@@ -0,0 +1,83 @@
import 'dart:typed_data';
/// Tracks sent public channel messages for echo detection
///
/// When a message is sent to the public channel, it's encrypted with AES128-ECB
/// which is deterministic. When another node receives and rebroadcasts it,
/// the raw packet will be byte-for-byte identical. We can detect these echoes
/// by comparing the raw packet data from PUSH_CODE_LOG_RX_DATA (0x88) against
/// packets we've sent.
class SentMessageTracker {
/// Unique identifier for the message (timestamp-based)
final String messageId;
/// SHA256 hash of the encrypted packet for fast O(1) lookup
final String packetHashHex;
/// Original raw encrypted packet bytes (for verification)
final Uint8List? rawPacket;
/// When the message was sent
final DateTime sentTime;
/// When this tracker expires (default: 5 minutes)
final DateTime expiryTime;
/// Number of times we've detected this message being rebroadcast
int echoCount;
/// Unique echo paths detected (SNR/RSSI signatures)
/// Format: "snr_rssi" e.g., "20_-56" means SNR=5.0dB (20/4), RSSI=-56dBm
final Set<String> uniqueEchoPaths;
/// Timestamps when echoes were detected
final List<DateTime> echoTimestamps;
SentMessageTracker({
required this.messageId,
required this.packetHashHex,
this.rawPacket,
required this.sentTime,
required this.expiryTime,
this.echoCount = 0,
Set<String>? uniqueEchoPaths,
List<DateTime>? echoTimestamps,
}) : uniqueEchoPaths = uniqueEchoPaths ?? {},
echoTimestamps = echoTimestamps ?? [];
/// Check if this tracker has expired
bool get isExpired => DateTime.now().isAfter(expiryTime);
/// Time until expiry
Duration get timeUntilExpiry => expiryTime.difference(DateTime.now());
/// Add an echo detection
void addEcho(int snrRaw, int rssiDbm) {
echoCount++;
uniqueEchoPaths.add('${snrRaw}_$rssiDbm');
echoTimestamps.add(DateTime.now());
}
/// Get the SNR in dB from raw value
static double snrRawToDb(int snrRaw) {
return snrRaw.toSigned(8) / 4.0;
}
/// Get formatted echo statistics
String get echoStats {
if (echoCount == 0) return 'No echoes detected';
if (echoCount == 1) return '1 echo from ${uniqueEchoPaths.length} path(s)';
return '$echoCount echoes from ${uniqueEchoPaths.length} path(s)';
}
/// Get average time to first echo
Duration? get timeToFirstEcho {
if (echoTimestamps.isEmpty) return null;
return echoTimestamps.first.difference(sentTime);
}
@override
String toString() {
return 'SentMessageTracker(id=$messageId, echoes=$echoCount, paths=${uniqueEchoPaths.length}, expired=$isExpired)';
}
}

View File

@@ -0,0 +1,71 @@
/// SSE Server Configuration Model
///
/// Configuration for the SSE (Server-Sent Events) web server that enables
/// multiple app instances to share a single MeshCore BLE device.
class SseServerConfig {
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
final String host;
/// Server port (default: 12929)
final int port;
/// Whether the SSE server is enabled
final bool enabled;
/// Optional authentication token for basic security
/// Clients must include this token in Authorization header
final String? authToken;
const SseServerConfig({
this.host = '0.0.0.0',
this.port = 12929,
this.enabled = false,
this.authToken,
});
/// Create a copy with updated fields
SseServerConfig copyWith({
String? host,
int? port,
bool? enabled,
String? authToken,
}) {
return SseServerConfig(
host: host ?? this.host,
port: port ?? this.port,
enabled: enabled ?? this.enabled,
authToken: authToken ?? this.authToken,
);
}
/// Get server URL for clients to connect to
String getServerUrl({String? ipAddress}) {
final ip = ipAddress ?? host;
return 'http://$ip:$port';
}
/// Convert to JSON for persistence
Map<String, dynamic> toJson() {
return {
'host': host,
'port': port,
'enabled': enabled,
'authToken': authToken,
};
}
/// Create from JSON
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
return SseServerConfig(
host: json['host'] as String? ?? '0.0.0.0',
port: json['port'] as int? ?? 12929,
enabled: json['enabled'] as bool? ?? false,
authToken: json['authToken'] as String?,
);
}
@override
String toString() {
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
}
}

View File

@@ -0,0 +1,52 @@
/// Information about an available app update
class UpdateInfo {
final bool isAvailable;
final String currentCommitHash;
final String? latestCommitHash;
final String? downloadUrl;
final String? buildId;
final String? timestamp;
const UpdateInfo({
required this.isAvailable,
required this.currentCommitHash,
this.latestCommitHash,
this.downloadUrl,
this.buildId,
this.timestamp,
});
/// Factory constructor for when no update is available
factory UpdateInfo.noUpdate(String currentCommitHash) {
return UpdateInfo(
isAvailable: false,
currentCommitHash: currentCommitHash,
);
}
/// Factory constructor for when an update is available
factory UpdateInfo.available({
required String currentCommitHash,
required String latestCommitHash,
required String downloadUrl,
String? buildId,
String? timestamp,
}) {
return UpdateInfo(
isAvailable: true,
currentCommitHash: currentCommitHash,
latestCommitHash: latestCommitHash,
downloadUrl: downloadUrl,
buildId: buildId,
timestamp: timestamp,
);
}
@override
String toString() {
return 'UpdateInfo(isAvailable: $isAvailable, '
'current: $currentCommitHash, '
'latest: $latestCommitHash, '
'downloadUrl: $downloadUrl)';
}
}