mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: MeshCore SAR - Flutter BLE mesh radio companion app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
40
lib/models/advert_location.dart
Normal file
40
lib/models/advert_location.dart
Normal 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);
|
||||
}
|
||||
121
lib/models/ble_packet_log.dart
Normal file
121
lib/models/ble_packet_log.dart
Normal 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
173
lib/models/channel.dart
Normal 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
364
lib/models/contact.dart
Normal 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;
|
||||
}
|
||||
76
lib/models/contact_telemetry.dart
Normal file
76
lib/models/contact_telemetry.dart
Normal 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
282
lib/models/device_info.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
106
lib/models/location_trail.dart
Normal file
106
lib/models/location_trail.dart
Normal 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
420
lib/models/map_drawing.dart
Normal 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
209
lib/models/map_layer.dart
Normal 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
491
lib/models/message.dart
Normal 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;
|
||||
}
|
||||
111
lib/models/room_login_state.dart
Normal file
111
lib/models/room_login_state.dart
Normal 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
197
lib/models/sar_marker.dart
Normal 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;
|
||||
}
|
||||
302
lib/models/sar_template.dart
Normal file
302
lib/models/sar_template.dart
Normal 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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
83
lib/models/sent_message_tracker.dart
Normal file
83
lib/models/sent_message_tracker.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
71
lib/models/sse_server_config.dart
Normal file
71
lib/models/sse_server_config.dart
Normal 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})';
|
||||
}
|
||||
}
|
||||
52
lib/models/update_info.dart
Normal file
52
lib/models/update_info.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user